Widen the front component event allow-list (#22616)
Front components are third-party UI that runs in a sandboxed worker, so every DOM event reaching them has to be on an explicit allow-list. That list was small: mostly click, focus and pointer events. This adds touch, drag and drop, focusin/focusout, animationend/transitionend and scrollend, plus load/error on `<img>` and toggle on `<details>`/`<dialog>`. Two of them need the host to do more than forward the event: - react-dom has no `onFocusIn`/`onFocusOut` props, so the host attaches those two with `addEventListener` instead. - a browser only fires `drop` on an element whose `dragover` default was prevented, and the component's own `preventDefault` arrives too late across the async worker boundary. The host prevents it synchronously as soon as the component declares either handler. Touch events carry their coordinates on `changedTouches`, so the first touch fills the existing coordinate fields. Still not crossing, since each would need a new serialized field: touch lists, `animationName`/`propertyName`/`elapsedTime`, toggle `newState` and `dataTransfer`. The diff also renames a few things it touches (`filterProps` and `EventToReact` in particular) so the host-side event path reads in order. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22616?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+9
@@ -0,0 +1,9 @@
|
||||
import { type ReactUnsupportedEventType } from '@/host/types/ReactUnsupportedEventType';
|
||||
|
||||
export const REACT_UNSUPPORTED_EVENT_PROP_TO_EVENT_TYPE: Record<
|
||||
string,
|
||||
ReactUnsupportedEventType
|
||||
> = {
|
||||
onFocusIn: 'focusin',
|
||||
onFocusOut: 'focusout',
|
||||
};
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import { type ReactUnsupportedEventHandlers } from '@/host/types/ReactUnsupportedEventHandlers';
|
||||
import { createReactUnsupportedEventListenerRef } from '@/host/utils/createReactUnsupportedEventListenerRef';
|
||||
|
||||
export const useReactUnsupportedEventListenerRef = (
|
||||
reactUnsupportedEventHandlers: ReactUnsupportedEventHandlers,
|
||||
): ((element: Element | null) => void) | undefined => {
|
||||
const latestHandlersRef = useRef(reactUnsupportedEventHandlers);
|
||||
latestHandlersRef.current = reactUnsupportedEventHandlers;
|
||||
|
||||
const [reactUnsupportedEventListenerRef] = useState(() =>
|
||||
createReactUnsupportedEventListenerRef(latestHandlersRef),
|
||||
);
|
||||
|
||||
const hasReactUnsupportedEventHandlers =
|
||||
Object.keys(reactUnsupportedEventHandlers).length > 0;
|
||||
|
||||
return hasReactUnsupportedEventHandlers
|
||||
? reactUnsupportedEventListenerRef
|
||||
: undefined;
|
||||
};
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { type ReactUnsupportedEventType } from '@/host/types/ReactUnsupportedEventType';
|
||||
|
||||
export type ReactUnsupportedEventHandlers = Partial<
|
||||
Record<ReactUnsupportedEventType, (event: Event) => void>
|
||||
>;
|
||||
@@ -0,0 +1 @@
|
||||
export type ReactUnsupportedEventType = 'focusin' | 'focusout';
|
||||
@@ -0,0 +1,3 @@
|
||||
export type RemoteEventHandler = (event: {
|
||||
preventDefault: () => void;
|
||||
}) => void;
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
import { DOM_EVENT_TYPE_TO_REACT_PROP } from '@/constants/DomEventTypeToReactProp';
|
||||
|
||||
import { buildHostReactPropsFromRemoteProps } from '../buildHostReactPropsFromRemoteProps';
|
||||
|
||||
describe('buildHostReactPropsFromRemoteProps', () => {
|
||||
it('should drop internal remote-dom props', () => {
|
||||
const result = buildHostReactPropsFromRemoteProps(
|
||||
{ element: {}, receiver: {}, components: {}, id: 'keep' },
|
||||
'div',
|
||||
);
|
||||
|
||||
expect(result).toEqual({ id: 'keep' });
|
||||
});
|
||||
|
||||
it('should drop undefined values', () => {
|
||||
const result = buildHostReactPropsFromRemoteProps(
|
||||
{ title: undefined, id: 'x' },
|
||||
'div',
|
||||
);
|
||||
|
||||
expect('title' in result).toBe(false);
|
||||
expect(result.id).toBe('x');
|
||||
});
|
||||
|
||||
it('should parse the style string into an object', () => {
|
||||
const result = buildHostReactPropsFromRemoteProps(
|
||||
{ style: 'color: red' },
|
||||
'div',
|
||||
);
|
||||
|
||||
expect(result.style).toEqual({ color: 'red' });
|
||||
});
|
||||
|
||||
it('should wrap function event handlers and normalize their key', () => {
|
||||
const onClick = jest.fn();
|
||||
const result = buildHostReactPropsFromRemoteProps({ onClick }, 'div');
|
||||
|
||||
expect(typeof result.onClick).toBe('function');
|
||||
expect(result.onClick).not.toBe(onClick);
|
||||
});
|
||||
|
||||
it('should normalize and wrap newly allowed event handlers', () => {
|
||||
const handler = jest.fn();
|
||||
const result = buildHostReactPropsFromRemoteProps(
|
||||
{
|
||||
onTouchstart: handler,
|
||||
onDragstart: handler,
|
||||
onDrop: handler,
|
||||
onAnimationend: handler,
|
||||
onTransitionend: handler,
|
||||
onScrollend: handler,
|
||||
onToggle: handler,
|
||||
onLoad: handler,
|
||||
},
|
||||
'div',
|
||||
);
|
||||
|
||||
expect(typeof result.onTouchStart).toBe('function');
|
||||
expect(typeof result.onDragStart).toBe('function');
|
||||
expect(typeof result.onDrop).toBe('function');
|
||||
expect(typeof result.onAnimationEnd).toBe('function');
|
||||
expect(typeof result.onTransitionEnd).toBe('function');
|
||||
expect(typeof result.onScrollEnd).toBe('function');
|
||||
expect(typeof result.onToggle).toBe('function');
|
||||
expect(typeof result.onLoad).toBe('function');
|
||||
});
|
||||
|
||||
it('should normalize focusin and focusout handlers to their react-style keys', () => {
|
||||
const handler = jest.fn();
|
||||
const result = buildHostReactPropsFromRemoteProps(
|
||||
{ onFocusin: handler, onFocusout: handler },
|
||||
'div',
|
||||
);
|
||||
|
||||
expect(typeof result.onFocusIn).toBe('function');
|
||||
expect(typeof result.onFocusOut).toBe('function');
|
||||
});
|
||||
|
||||
it('should drop event-handler props whose value is not a function', () => {
|
||||
const result = buildHostReactPropsFromRemoteProps(
|
||||
{ onClick: 'alert(1)', onmouseover: 'x' },
|
||||
'div',
|
||||
);
|
||||
|
||||
expect('onClick' in result).toBe(false);
|
||||
expect('onMouseOver' in result).toBe(false);
|
||||
expect('onmouseover' in result).toBe(false);
|
||||
});
|
||||
|
||||
it('should drop handler props whose event type is not allow-listed', () => {
|
||||
const handler = jest.fn();
|
||||
const result = buildHostReactPropsFromRemoteProps(
|
||||
{
|
||||
onCopy: handler,
|
||||
onCut: handler,
|
||||
onPaste: handler,
|
||||
onSelect: handler,
|
||||
onBeforeInput: handler,
|
||||
},
|
||||
'div',
|
||||
);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should keep every allow-listed event under its react prop name', () => {
|
||||
const handler = jest.fn();
|
||||
|
||||
for (const reactProp of Object.values(DOM_EVENT_TYPE_TO_REACT_PROP)) {
|
||||
const result = buildHostReactPropsFromRemoteProps(
|
||||
{ [reactProp]: handler },
|
||||
'div',
|
||||
);
|
||||
|
||||
expect(Object.keys(result)).toEqual([reactProp]);
|
||||
}
|
||||
});
|
||||
|
||||
it('should drop capture-phase handler props', () => {
|
||||
const handler = jest.fn();
|
||||
const result = buildHostReactPropsFromRemoteProps(
|
||||
{ onClickCapture: handler, onKeyDownCapture: handler },
|
||||
'div',
|
||||
);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should drop a dangerous scheme on a navigation attribute', () => {
|
||||
const result = buildHostReactPropsFromRemoteProps(
|
||||
{ href: 'javascript:alert(1)' },
|
||||
'a',
|
||||
);
|
||||
|
||||
expect('href' in result).toBe(false);
|
||||
});
|
||||
|
||||
it('should keep a dangerous scheme on a non-navigation attribute', () => {
|
||||
const dataImage = 'data:image/png;base64,iVBOR';
|
||||
const result = buildHostReactPropsFromRemoteProps(
|
||||
{ src: dataImage },
|
||||
'img',
|
||||
);
|
||||
|
||||
expect(result.src).toBe(dataImage);
|
||||
});
|
||||
|
||||
it('should keep a safe url on a navigation attribute', () => {
|
||||
const result = buildHostReactPropsFromRemoteProps(
|
||||
{ href: 'https://twenty.com' },
|
||||
'a',
|
||||
);
|
||||
|
||||
expect(result.href).toBe('https://twenty.com');
|
||||
});
|
||||
|
||||
it('should forward arbitrary aria-* and data-* attributes', () => {
|
||||
const result = buildHostReactPropsFromRemoteProps(
|
||||
{
|
||||
'aria-selected': 'true',
|
||||
'aria-activedescendant': 'item-2',
|
||||
'data-state': 'open',
|
||||
'data-count': '3',
|
||||
},
|
||||
'div',
|
||||
);
|
||||
|
||||
expect(result['aria-selected']).toBe('true');
|
||||
expect(result['aria-activedescendant']).toBe('item-2');
|
||||
expect(result['data-state']).toBe('open');
|
||||
expect(result['data-count']).toBe('3');
|
||||
});
|
||||
|
||||
it('should forward the draggable attribute', () => {
|
||||
expect(
|
||||
buildHostReactPropsFromRemoteProps({ draggable: 'true' }, 'div')
|
||||
.draggable,
|
||||
).toBe('true');
|
||||
expect(
|
||||
buildHostReactPropsFromRemoteProps({ draggable: true }, 'div').draggable,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should still drop a non-function on* handler smuggled as a data-adjacent prop', () => {
|
||||
const result = buildHostReactPropsFromRemoteProps(
|
||||
{ onClick: 'alert(1)', 'data-state': 'open' },
|
||||
'div',
|
||||
);
|
||||
|
||||
expect('onClick' in result).toBe(false);
|
||||
expect(result['data-state']).toBe('open');
|
||||
});
|
||||
});
|
||||
+35
-35
@@ -7,46 +7,46 @@ const getProps = (element: ReactElement): Record<string, unknown> =>
|
||||
|
||||
describe('createCaretPreservingElement', () => {
|
||||
it('should create an element of the requested tag', () => {
|
||||
const element = createCaretPreservingElement(
|
||||
'input',
|
||||
{ type: 'text' },
|
||||
undefined,
|
||||
null,
|
||||
);
|
||||
const element = createCaretPreservingElement({
|
||||
htmlTag: 'input',
|
||||
reactBindableProps: { type: 'text' },
|
||||
hostEnforcedProps: undefined,
|
||||
setEditableFocused: null,
|
||||
});
|
||||
|
||||
expect(element.type).toBe('input');
|
||||
expect(getProps(element).type).toBe('text');
|
||||
});
|
||||
|
||||
it('should seed the initial value from value', () => {
|
||||
const element = createCaretPreservingElement(
|
||||
'input',
|
||||
{ value: 'hello' },
|
||||
undefined,
|
||||
null,
|
||||
);
|
||||
const element = createCaretPreservingElement({
|
||||
htmlTag: 'input',
|
||||
reactBindableProps: { value: 'hello' },
|
||||
hostEnforcedProps: undefined,
|
||||
setEditableFocused: null,
|
||||
});
|
||||
|
||||
expect(getProps(element).defaultValue).toBe('hello');
|
||||
});
|
||||
|
||||
it('should prefer defaultValue over value for the initial value', () => {
|
||||
const element = createCaretPreservingElement(
|
||||
'input',
|
||||
{ value: 'v', defaultValue: 'd' },
|
||||
undefined,
|
||||
null,
|
||||
);
|
||||
const element = createCaretPreservingElement({
|
||||
htmlTag: 'input',
|
||||
reactBindableProps: { value: 'v', defaultValue: 'd' },
|
||||
hostEnforcedProps: undefined,
|
||||
setEditableFocused: null,
|
||||
});
|
||||
|
||||
expect(getProps(element).defaultValue).toBe('d');
|
||||
});
|
||||
|
||||
it('should apply forced props', () => {
|
||||
const element = createCaretPreservingElement(
|
||||
'textarea',
|
||||
{},
|
||||
{ readOnly: true },
|
||||
null,
|
||||
);
|
||||
it('should apply host enforced props', () => {
|
||||
const element = createCaretPreservingElement({
|
||||
htmlTag: 'textarea',
|
||||
reactBindableProps: {},
|
||||
hostEnforcedProps: { readOnly: true },
|
||||
setEditableFocused: null,
|
||||
});
|
||||
|
||||
expect(getProps(element).readOnly).toBe(true);
|
||||
});
|
||||
@@ -54,12 +54,12 @@ describe('createCaretPreservingElement', () => {
|
||||
it('should notify focus state and forward the original focus handler', () => {
|
||||
const setEditableFocused = jest.fn();
|
||||
const onFocus = jest.fn();
|
||||
const element = createCaretPreservingElement(
|
||||
'input',
|
||||
{ onFocus },
|
||||
undefined,
|
||||
const element = createCaretPreservingElement({
|
||||
htmlTag: 'input',
|
||||
reactBindableProps: { onFocus },
|
||||
hostEnforcedProps: undefined,
|
||||
setEditableFocused,
|
||||
);
|
||||
});
|
||||
|
||||
const event = {} as never;
|
||||
(getProps(element).onFocus as (event: unknown) => void)(event);
|
||||
@@ -70,12 +70,12 @@ describe('createCaretPreservingElement', () => {
|
||||
|
||||
it('should notify blur state', () => {
|
||||
const setEditableFocused = jest.fn();
|
||||
const element = createCaretPreservingElement(
|
||||
'input',
|
||||
{},
|
||||
undefined,
|
||||
const element = createCaretPreservingElement({
|
||||
htmlTag: 'input',
|
||||
reactBindableProps: {},
|
||||
hostEnforcedProps: undefined,
|
||||
setEditableFocused,
|
||||
);
|
||||
});
|
||||
|
||||
(getProps(element).onBlur as (event: unknown) => void)({} as never);
|
||||
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { createDropTargetGuardProps } from '../createDropTargetGuardProps';
|
||||
|
||||
describe('createDropTargetGuardProps', () => {
|
||||
it('should return undefined without drag over or drop handlers', () => {
|
||||
expect(createDropTargetGuardProps({ onClick: jest.fn() })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should prevent default on drag over and forward to the remote handler', () => {
|
||||
const onDragOver = jest.fn();
|
||||
const props = createDropTargetGuardProps({ onDragOver });
|
||||
const event = { preventDefault: jest.fn() };
|
||||
|
||||
(props?.onDragOver as (event: unknown) => void)(event);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(onDragOver).toHaveBeenCalledWith(event);
|
||||
});
|
||||
|
||||
it('should prevent default on drag over when only a drop handler exists', () => {
|
||||
const onDrop = jest.fn();
|
||||
const props = createDropTargetGuardProps({ onDrop });
|
||||
const dragOverEvent = { preventDefault: jest.fn() };
|
||||
const dropEvent = { preventDefault: jest.fn() };
|
||||
|
||||
(props?.onDragOver as (event: unknown) => void)(dragOverEvent);
|
||||
(props?.onDrop as (event: unknown) => void)(dropEvent);
|
||||
|
||||
expect(dragOverEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(dropEvent.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(onDrop).toHaveBeenCalledWith(dropEvent);
|
||||
});
|
||||
|
||||
it('should prevent default without forwarding to a non-function handler', () => {
|
||||
const props = createDropTargetGuardProps({
|
||||
onDragOver: 'alert(1)',
|
||||
onDrop: jest.fn(),
|
||||
});
|
||||
const event = { preventDefault: jest.fn() };
|
||||
|
||||
(props?.onDragOver as (event: unknown) => void)(event);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+93
-1
@@ -1,10 +1,15 @@
|
||||
import './setupServerRenderingGlobals';
|
||||
|
||||
import { createElement } from 'react';
|
||||
import { act, createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { createHtmlHostWrapper } from '../createHtmlHostWrapper';
|
||||
|
||||
(
|
||||
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const renderWrapper = (
|
||||
htmlTag: string,
|
||||
props: Record<string, unknown>,
|
||||
@@ -93,3 +98,90 @@ describe('createHtmlHostWrapper prop hardening', () => {
|
||||
expect(markup).toContain(dataImageUrl);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createHtmlHostWrapper client events', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('should forward focusin through a native listener', () => {
|
||||
const handleFocusIn = jest.fn();
|
||||
const Wrapper = createHtmlHostWrapper('div');
|
||||
|
||||
act(() => {
|
||||
root.render(createElement(Wrapper, { onFocusin: handleFocusIn }));
|
||||
});
|
||||
|
||||
const node = container.firstElementChild as HTMLElement;
|
||||
act(() => {
|
||||
node.dispatchEvent(new Event('focusin', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(handleFocusIn).toHaveBeenCalledTimes(1);
|
||||
expect(handleFocusIn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'focusin' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should stop forwarding focusin after the handler prop is removed', () => {
|
||||
const handleFocusIn = jest.fn();
|
||||
const Wrapper = createHtmlHostWrapper('div');
|
||||
|
||||
act(() => {
|
||||
root.render(createElement(Wrapper, { onFocusin: handleFocusIn }));
|
||||
});
|
||||
|
||||
const node = container.firstElementChild as HTMLElement;
|
||||
|
||||
act(() => {
|
||||
root.render(createElement(Wrapper, {}));
|
||||
});
|
||||
act(() => {
|
||||
node.dispatchEvent(new Event('focusin', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(handleFocusIn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should prevent default on dragover when a remote drop handler exists', () => {
|
||||
const handleDrop = jest.fn();
|
||||
const Wrapper = createHtmlHostWrapper('div');
|
||||
|
||||
act(() => {
|
||||
root.render(createElement(Wrapper, { onDrop: handleDrop }));
|
||||
});
|
||||
|
||||
const node = container.firstElementChild as HTMLElement;
|
||||
const dragOverEvent = new Event('dragover', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
act(() => {
|
||||
node.dispatchEvent(dragOverEvent);
|
||||
});
|
||||
|
||||
expect(dragOverEvent.defaultPrevented).toBe(true);
|
||||
|
||||
const dropEvent = new Event('drop', { bubbles: true, cancelable: true });
|
||||
act(() => {
|
||||
node.dispatchEvent(dropEvent);
|
||||
});
|
||||
|
||||
expect(dropEvent.defaultPrevented).toBe(true);
|
||||
expect(handleDrop).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'drop' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
import { createReactUnsupportedEventListenerRef } from '../createReactUnsupportedEventListenerRef';
|
||||
|
||||
describe('createReactUnsupportedEventListenerRef', () => {
|
||||
it('should forward focusin events to the latest handler', () => {
|
||||
const initialHandler = jest.fn();
|
||||
const handlersRef = { current: { focusin: initialHandler } };
|
||||
const ref = createReactUnsupportedEventListenerRef(handlersRef);
|
||||
const node = document.createElement('div');
|
||||
|
||||
ref(node);
|
||||
node.dispatchEvent(new Event('focusin'));
|
||||
|
||||
expect(initialHandler).toHaveBeenCalledTimes(1);
|
||||
|
||||
const replacementHandler = jest.fn();
|
||||
handlersRef.current = { focusin: replacementHandler };
|
||||
node.dispatchEvent(new Event('focusin'));
|
||||
|
||||
expect(initialHandler).toHaveBeenCalledTimes(1);
|
||||
expect(replacementHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should ignore events without a registered handler', () => {
|
||||
const ref = createReactUnsupportedEventListenerRef({ current: {} });
|
||||
const node = document.createElement('div');
|
||||
|
||||
ref(node);
|
||||
|
||||
expect(() => node.dispatchEvent(new Event('focusout'))).not.toThrow();
|
||||
});
|
||||
|
||||
it('should stop forwarding after the node detaches', () => {
|
||||
const focusinHandler = jest.fn();
|
||||
const ref = createReactUnsupportedEventListenerRef({
|
||||
current: { focusin: focusinHandler },
|
||||
});
|
||||
const node = document.createElement('div');
|
||||
|
||||
ref(node);
|
||||
ref(null);
|
||||
node.dispatchEvent(new Event('focusin'));
|
||||
|
||||
expect(focusinHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should move listeners when the node changes', () => {
|
||||
const focusinHandler = jest.fn();
|
||||
const ref = createReactUnsupportedEventListenerRef({
|
||||
current: { focusin: focusinHandler },
|
||||
});
|
||||
const firstNode = document.createElement('div');
|
||||
const secondNode = document.createElement('div');
|
||||
|
||||
ref(firstNode);
|
||||
ref(secondNode);
|
||||
firstNode.dispatchEvent(new Event('focusin'));
|
||||
secondNode.dispatchEvent(new Event('focusin'));
|
||||
|
||||
expect(focusinHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { extractReactUnsupportedEventHandlers } from '../extractReactUnsupportedEventHandlers';
|
||||
|
||||
describe('extractReactUnsupportedEventHandlers', () => {
|
||||
it('should extract focusin and focusout handlers from react props', () => {
|
||||
const onFocusIn = jest.fn();
|
||||
const onFocusOut = jest.fn();
|
||||
|
||||
const { reactUnsupportedEventHandlers, reactBindableProps } =
|
||||
extractReactUnsupportedEventHandlers({ onFocusIn, onFocusOut, id: 'x' });
|
||||
|
||||
expect(reactUnsupportedEventHandlers).toEqual({
|
||||
focusin: onFocusIn,
|
||||
focusout: onFocusOut,
|
||||
});
|
||||
expect(reactBindableProps).toEqual({ id: 'x' });
|
||||
});
|
||||
|
||||
it('should drop a react-unsupported handler whose value is not a function', () => {
|
||||
const { reactUnsupportedEventHandlers, reactBindableProps } =
|
||||
extractReactUnsupportedEventHandlers({ onFocusIn: 'alert(1)' });
|
||||
|
||||
expect(reactUnsupportedEventHandlers).toEqual({});
|
||||
expect('onFocusIn' in reactBindableProps).toBe(false);
|
||||
});
|
||||
|
||||
it('should leave other props untouched', () => {
|
||||
const onClick = jest.fn();
|
||||
|
||||
const { reactUnsupportedEventHandlers, reactBindableProps } =
|
||||
extractReactUnsupportedEventHandlers({ onClick });
|
||||
|
||||
expect(reactUnsupportedEventHandlers).toEqual({});
|
||||
expect(reactBindableProps).toEqual({ onClick });
|
||||
});
|
||||
});
|
||||
@@ -1,92 +0,0 @@
|
||||
import { filterProps } from '../filterProps';
|
||||
|
||||
const filter = (props: Record<string, unknown>, htmlTag: string) =>
|
||||
filterProps(props, htmlTag) as Record<string, unknown>;
|
||||
|
||||
describe('filterProps', () => {
|
||||
it('should drop internal remote-dom props', () => {
|
||||
const result = filter(
|
||||
{ element: {}, receiver: {}, components: {}, id: 'keep' },
|
||||
'div',
|
||||
);
|
||||
|
||||
expect(result).toEqual({ id: 'keep' });
|
||||
});
|
||||
|
||||
it('should drop undefined values', () => {
|
||||
const result = filter({ title: undefined, id: 'x' }, 'div');
|
||||
|
||||
expect('title' in result).toBe(false);
|
||||
expect(result.id).toBe('x');
|
||||
});
|
||||
|
||||
it('should parse the style string into an object', () => {
|
||||
const result = filter({ style: 'color: red' }, 'div');
|
||||
|
||||
expect(result.style).toEqual({ color: 'red' });
|
||||
});
|
||||
|
||||
it('should wrap function event handlers and normalize their key', () => {
|
||||
const onClick = jest.fn();
|
||||
const result = filter({ onClick }, 'div');
|
||||
|
||||
expect(typeof result.onClick).toBe('function');
|
||||
expect(result.onClick).not.toBe(onClick);
|
||||
});
|
||||
|
||||
it('should drop event-handler props whose value is not a function', () => {
|
||||
const result = filter({ onClick: 'alert(1)', onmouseover: 'x' }, 'div');
|
||||
|
||||
expect('onClick' in result).toBe(false);
|
||||
expect('onMouseOver' in result).toBe(false);
|
||||
expect('onmouseover' in result).toBe(false);
|
||||
});
|
||||
|
||||
it('should drop a dangerous scheme on a navigation attribute', () => {
|
||||
const result = filter({ href: 'javascript:alert(1)' }, 'a');
|
||||
|
||||
expect('href' in result).toBe(false);
|
||||
});
|
||||
|
||||
it('should keep a dangerous scheme on a non-navigation attribute', () => {
|
||||
const dataImage = 'data:image/png;base64,iVBOR';
|
||||
const result = filter({ src: dataImage }, 'img');
|
||||
|
||||
expect(result.src).toBe(dataImage);
|
||||
});
|
||||
|
||||
it('should keep a safe url on a navigation attribute', () => {
|
||||
const result = filter({ href: 'https://twenty.com' }, 'a');
|
||||
|
||||
expect(result.href).toBe('https://twenty.com');
|
||||
});
|
||||
|
||||
it('should forward arbitrary aria-* and data-* attributes', () => {
|
||||
const result = filter(
|
||||
{
|
||||
'aria-selected': 'true',
|
||||
'aria-activedescendant': 'item-2',
|
||||
'data-state': 'open',
|
||||
'data-count': '3',
|
||||
},
|
||||
'div',
|
||||
);
|
||||
|
||||
expect(result['aria-selected']).toBe('true');
|
||||
expect(result['aria-activedescendant']).toBe('item-2');
|
||||
expect(result['data-state']).toBe('open');
|
||||
expect(result['data-count']).toBe('3');
|
||||
});
|
||||
|
||||
it('should forward the draggable attribute', () => {
|
||||
expect(filter({ draggable: 'true' }, 'div').draggable).toBe('true');
|
||||
expect(filter({ draggable: true }, 'div').draggable).toBe(true);
|
||||
});
|
||||
|
||||
it('should still drop a non-function on* handler smuggled as a data-adjacent prop', () => {
|
||||
const result = filter({ onClick: 'alert(1)', 'data-state': 'open' }, 'div');
|
||||
|
||||
expect('onClick' in result).toBe(false);
|
||||
expect(result['data-state']).toBe('open');
|
||||
});
|
||||
});
|
||||
+46
@@ -34,6 +34,52 @@ describe('serializeEvent', () => {
|
||||
expect(result).toEqual({ type: 'wheel' });
|
||||
});
|
||||
|
||||
it('should map first changed touch coordinates into coordinate fields', () => {
|
||||
const result = serializeEvent({
|
||||
type: 'touchstart',
|
||||
changedTouches: {
|
||||
length: 1,
|
||||
0: {
|
||||
clientX: 10,
|
||||
clientY: 20,
|
||||
pageX: 30,
|
||||
pageY: 40,
|
||||
screenX: 50,
|
||||
screenY: 60,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'touchstart',
|
||||
clientX: 10,
|
||||
clientY: 20,
|
||||
pageX: 30,
|
||||
pageY: 40,
|
||||
screenX: 50,
|
||||
screenY: 60,
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep own coordinates over changed touch coordinates', () => {
|
||||
const result = serializeEvent({
|
||||
type: 'mousemove',
|
||||
clientX: 1,
|
||||
changedTouches: { length: 1, 0: { clientX: 99 } },
|
||||
});
|
||||
|
||||
expect(result.clientX).toBe(1);
|
||||
});
|
||||
|
||||
it('should ignore an empty changed touches list', () => {
|
||||
const result = serializeEvent({
|
||||
type: 'touchend',
|
||||
changedTouches: { length: 0 },
|
||||
});
|
||||
|
||||
expect(result).toEqual({ type: 'touchend' });
|
||||
});
|
||||
|
||||
it('should extract safe target properties', () => {
|
||||
const result = serializeEvent({
|
||||
type: 'change',
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import { isNumber, isObject } from '@sniptt/guards';
|
||||
|
||||
import { type SerializedEventData } from '@/types/SerializedEventData';
|
||||
|
||||
const TOUCH_COORDINATE_KEYS = [
|
||||
'clientX',
|
||||
'clientY',
|
||||
'pageX',
|
||||
'pageY',
|
||||
'screenX',
|
||||
'screenY',
|
||||
] as const;
|
||||
|
||||
export const applyFirstChangedTouchCoordinates = (
|
||||
serializedEvent: SerializedEventData,
|
||||
domEvent: Record<string, unknown>,
|
||||
): void => {
|
||||
const domEventHasDirectCoordinates = isNumber(domEvent.clientX);
|
||||
|
||||
if (domEventHasDirectCoordinates || !isObject(domEvent.changedTouches)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstChangedTouch = (
|
||||
domEvent.changedTouches as Record<number, unknown>
|
||||
)[0];
|
||||
|
||||
if (!isObject(firstChangedTouch)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const coordinateKey of TOUCH_COORDINATE_KEYS) {
|
||||
const coordinateValue = (firstChangedTouch as Record<string, unknown>)[
|
||||
coordinateKey
|
||||
];
|
||||
|
||||
if (isNumber(coordinateValue)) {
|
||||
serializedEvent[coordinateKey] = coordinateValue;
|
||||
}
|
||||
}
|
||||
};
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import { isFunction, isUndefined } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { DOM_EVENT_TYPE_TO_REACT_PROP } from '@/constants/DomEventTypeToReactProp';
|
||||
import { hasDangerousUrlScheme } from '@/host/utils/hasDangerousUrlScheme';
|
||||
import { isEventHandlerKey } from '@/host/utils/isEventHandlerKey';
|
||||
import { isNavigationUrlAttribute } from '@/host/utils/isNavigationUrlAttribute';
|
||||
import { parseCssString } from '@/host/utils/parseCssString';
|
||||
import { wrapEventHandler } from '@/host/utils/wrapEventHandler';
|
||||
import { type SerializedEventData } from '@/types/SerializedEventData';
|
||||
|
||||
const INTERNAL_PROPS = new Set(['element', 'receiver', 'components']);
|
||||
|
||||
// Both spellings are indexed: dblclick arrives as ondblclick or onDoubleClick.
|
||||
const LOWERCASE_EVENT_PROP_TO_REACT_PROP: Record<string, string> =
|
||||
Object.fromEntries(
|
||||
Object.entries(DOM_EVENT_TYPE_TO_REACT_PROP).flatMap(
|
||||
([domEventType, reactProp]) => [
|
||||
[`on${domEventType}`, reactProp],
|
||||
[reactProp.toLowerCase(), reactProp],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
export const buildHostReactPropsFromRemoteProps = (
|
||||
remoteProps: Record<string, unknown>,
|
||||
htmlTag: string,
|
||||
): Record<string, unknown> => {
|
||||
const hostReactProps: Record<string, unknown> = {};
|
||||
|
||||
for (const [remotePropName, remotePropValue] of Object.entries(remoteProps)) {
|
||||
if (INTERNAL_PROPS.has(remotePropName) || isUndefined(remotePropValue)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (remotePropName === 'style') {
|
||||
hostReactProps.style = parseCssString(
|
||||
remotePropValue as string | undefined,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// A guest can put any property name on the wire, and React binds every on*
|
||||
// prop it recognizes, so unmapped handler names are dropped.
|
||||
if (isEventHandlerKey(remotePropName)) {
|
||||
const reactPropName =
|
||||
LOWERCASE_EVENT_PROP_TO_REACT_PROP[remotePropName.toLowerCase()];
|
||||
|
||||
if (isDefined(reactPropName) && isFunction(remotePropValue)) {
|
||||
hostReactProps[reactPropName] = wrapEventHandler(
|
||||
remotePropValue as (detail: SerializedEventData) => void,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
isNavigationUrlAttribute(htmlTag, remotePropName) &&
|
||||
hasDangerousUrlScheme(remotePropValue)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
hostReactProps[remotePropName] = remotePropValue;
|
||||
}
|
||||
|
||||
return hostReactProps;
|
||||
};
|
||||
+18
-8
@@ -7,19 +7,28 @@ import { syncValuePreservingCaret } from '@/host/utils/syncValuePreservingCaret'
|
||||
|
||||
type CaretPreservingElement = HTMLInputElement | HTMLTextAreaElement;
|
||||
|
||||
export const createCaretPreservingElement = (
|
||||
htmlTag: 'input' | 'textarea',
|
||||
reactProps: Record<string, unknown>,
|
||||
forcedProps: Record<string, unknown> | undefined,
|
||||
setEditableFocused: SetEditableFocused | null,
|
||||
) => {
|
||||
type CreateCaretPreservingElementParams = {
|
||||
htmlTag: 'input' | 'textarea';
|
||||
reactBindableProps: Record<string, unknown>;
|
||||
hostEnforcedProps: Record<string, unknown> | undefined;
|
||||
setEditableFocused: SetEditableFocused | null;
|
||||
reactUnsupportedEventListenerRef?: (node: Element | null) => void;
|
||||
};
|
||||
|
||||
export const createCaretPreservingElement = ({
|
||||
htmlTag,
|
||||
reactBindableProps,
|
||||
hostEnforcedProps,
|
||||
setEditableFocused,
|
||||
reactUnsupportedEventListenerRef,
|
||||
}: CreateCaretPreservingElementParams) => {
|
||||
const {
|
||||
value,
|
||||
defaultValue,
|
||||
onFocus: forwardedOnFocus,
|
||||
onBlur: forwardedOnBlur,
|
||||
...rest
|
||||
} = reactProps;
|
||||
} = reactBindableProps;
|
||||
const initialValue = isNonEmptyString(defaultValue)
|
||||
? defaultValue
|
||||
: isNonEmptyString(value)
|
||||
@@ -42,11 +51,12 @@ export const createCaretPreservingElement = (
|
||||
|
||||
return React.createElement(htmlTag, {
|
||||
...rest,
|
||||
...forcedProps,
|
||||
...hostEnforcedProps,
|
||||
defaultValue: initialValue,
|
||||
onFocus: handleFocus,
|
||||
onBlur: handleBlur,
|
||||
ref: (node: CaretPreservingElement | null) => {
|
||||
reactUnsupportedEventListenerRef?.(node);
|
||||
if (!isDefined(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { isFunction } from '@sniptt/guards';
|
||||
|
||||
import { preventDefaultThenForwardToRemote } from '@/host/utils/preventDefaultThenForwardToRemote';
|
||||
|
||||
// A browser only fires drop on an element whose dragover default was prevented.
|
||||
export const createDropTargetGuardProps = (
|
||||
reactBindableProps: Record<string, unknown>,
|
||||
): Record<string, unknown> | undefined => {
|
||||
const remoteDragOverHandler = reactBindableProps.onDragOver;
|
||||
const remoteDropHandler = reactBindableProps.onDrop;
|
||||
|
||||
if (!isFunction(remoteDragOverHandler) && !isFunction(remoteDropHandler)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
onDragOver: preventDefaultThenForwardToRemote(remoteDragOverHandler),
|
||||
onDrop: preventDefaultThenForwardToRemote(remoteDropHandler),
|
||||
};
|
||||
};
|
||||
@@ -1,9 +1,13 @@
|
||||
import React, { useContext } from 'react';
|
||||
|
||||
import { FrontComponentInputFocusContext } from '@/host/contexts/FrontComponentInputFocusContext';
|
||||
import { useReactUnsupportedEventListenerRef } from '@/host/hooks/useReactUnsupportedEventListenerRef';
|
||||
import { buildHostReactPropsFromRemoteProps } from '@/host/utils/buildHostReactPropsFromRemoteProps';
|
||||
import { createCaretPreservingElement } from '@/host/utils/createCaretPreservingElement';
|
||||
import { filterProps } from '@/host/utils/filterProps';
|
||||
import { createDropTargetGuardProps } from '@/host/utils/createDropTargetGuardProps';
|
||||
import { extractReactUnsupportedEventHandlers } from '@/host/utils/extractReactUnsupportedEventHandlers';
|
||||
import { isTextLikeInputType } from '@/host/utils/isTextLikeInputType';
|
||||
import { preventDefaultThenForwardToRemote } from '@/host/utils/preventDefaultThenForwardToRemote';
|
||||
import { sanitizeIframeSandbox } from '@/host/utils/sanitizeIframeSandbox';
|
||||
|
||||
const VOID_ELEMENTS = new Set([
|
||||
@@ -31,49 +35,48 @@ export const createHtmlHostWrapper = (htmlTag: string) => {
|
||||
|
||||
return ({ children, ...props }: WrapperProps) => {
|
||||
const setEditableFocused = useContext(FrontComponentInputFocusContext);
|
||||
const reactProps = filterProps(props, htmlTag);
|
||||
|
||||
const forcedProps: Record<string, unknown> | undefined = isIframe
|
||||
? { sandbox: sanitizeIframeSandbox(reactProps.sandbox) }
|
||||
: isForm
|
||||
? {
|
||||
// The remote component's onSubmit is forwarded asynchronously across
|
||||
// the remote-dom boundary, so its preventDefault lands too late to
|
||||
// stop a native form submission (which navigates and closes the
|
||||
// page). Guard synchronously on the host while still forwarding the
|
||||
// event to the remote handler. (React 19 also blocks the previous
|
||||
// `action="javascript:void(0)"` guard.)
|
||||
onSubmit: (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const remoteOnSubmit = reactProps.onSubmit;
|
||||
// The remote prop is untrusted across the remote-dom boundary, so
|
||||
// it may not be a function — guard before invoking.
|
||||
if (typeof remoteOnSubmit === 'function') {
|
||||
(
|
||||
remoteOnSubmit as (
|
||||
event: React.FormEvent<HTMLFormElement>,
|
||||
) => void
|
||||
)(event);
|
||||
}
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
const { reactUnsupportedEventHandlers, reactBindableProps } =
|
||||
extractReactUnsupportedEventHandlers(
|
||||
buildHostReactPropsFromRemoteProps(props, htmlTag),
|
||||
);
|
||||
|
||||
const reactUnsupportedEventListenerRef =
|
||||
useReactUnsupportedEventListenerRef(reactUnsupportedEventHandlers);
|
||||
|
||||
const hostEnforcedProps: Record<string, unknown> = {
|
||||
...createDropTargetGuardProps(reactBindableProps),
|
||||
...(isIframe && {
|
||||
sandbox: sanitizeIframeSandbox(reactBindableProps.sandbox),
|
||||
}),
|
||||
// React 19 blocks the previous `action="javascript:void(0)"` guard.
|
||||
...(isForm && {
|
||||
onSubmit: preventDefaultThenForwardToRemote(
|
||||
reactBindableProps.onSubmit,
|
||||
),
|
||||
}),
|
||||
};
|
||||
|
||||
if (
|
||||
htmlTag === 'textarea' ||
|
||||
(htmlTag === 'input' && isTextLikeInputType(reactProps.type))
|
||||
(htmlTag === 'input' && isTextLikeInputType(reactBindableProps.type))
|
||||
) {
|
||||
return createCaretPreservingElement(
|
||||
return createCaretPreservingElement({
|
||||
htmlTag,
|
||||
reactProps,
|
||||
forcedProps,
|
||||
reactBindableProps,
|
||||
hostEnforcedProps,
|
||||
setEditableFocused,
|
||||
);
|
||||
reactUnsupportedEventListenerRef,
|
||||
});
|
||||
}
|
||||
|
||||
return React.createElement(
|
||||
htmlTag,
|
||||
{ ...reactProps, ...forcedProps },
|
||||
{
|
||||
...reactBindableProps,
|
||||
...hostEnforcedProps,
|
||||
ref: reactUnsupportedEventListenerRef,
|
||||
},
|
||||
isVoid ? undefined : children,
|
||||
);
|
||||
};
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import { type RefObject } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { REACT_UNSUPPORTED_EVENT_PROP_TO_EVENT_TYPE } from '@/host/constants/ReactUnsupportedEventPropToEventType';
|
||||
import { type ReactUnsupportedEventHandlers } from '@/host/types/ReactUnsupportedEventHandlers';
|
||||
import { type ReactUnsupportedEventType } from '@/host/types/ReactUnsupportedEventType';
|
||||
|
||||
const REACT_UNSUPPORTED_EVENT_TYPES = Object.values(
|
||||
REACT_UNSUPPORTED_EVENT_PROP_TO_EVENT_TYPE,
|
||||
);
|
||||
|
||||
export const createReactUnsupportedEventListenerRef = (
|
||||
latestHandlersRef: RefObject<ReactUnsupportedEventHandlers>,
|
||||
) => {
|
||||
let attachedElement: Element | null = null;
|
||||
|
||||
const forwardEventToLatestHandler = (event: Event) => {
|
||||
const latestHandler =
|
||||
latestHandlersRef.current[event.type as ReactUnsupportedEventType];
|
||||
latestHandler?.(event);
|
||||
};
|
||||
|
||||
return (element: Element | null) => {
|
||||
if (isDefined(attachedElement)) {
|
||||
for (const eventType of REACT_UNSUPPORTED_EVENT_TYPES) {
|
||||
attachedElement.removeEventListener(
|
||||
eventType,
|
||||
forwardEventToLatestHandler,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
attachedElement = element;
|
||||
|
||||
if (isDefined(element)) {
|
||||
for (const eventType of REACT_UNSUPPORTED_EVENT_TYPES) {
|
||||
element.addEventListener(eventType, forwardEventToLatestHandler);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { isFunction } from '@sniptt/guards';
|
||||
|
||||
import { REACT_UNSUPPORTED_EVENT_PROP_TO_EVENT_TYPE } from '@/host/constants/ReactUnsupportedEventPropToEventType';
|
||||
import { type ReactUnsupportedEventHandlers } from '@/host/types/ReactUnsupportedEventHandlers';
|
||||
|
||||
const REACT_UNSUPPORTED_EVENT_PROP_ENTRIES = Object.entries(
|
||||
REACT_UNSUPPORTED_EVENT_PROP_TO_EVENT_TYPE,
|
||||
);
|
||||
|
||||
export const extractReactUnsupportedEventHandlers = (
|
||||
hostReactProps: Record<string, unknown>,
|
||||
): {
|
||||
reactUnsupportedEventHandlers: ReactUnsupportedEventHandlers;
|
||||
reactBindableProps: Record<string, unknown>;
|
||||
} => {
|
||||
const reactUnsupportedEventHandlers: ReactUnsupportedEventHandlers = {};
|
||||
const reactBindableProps = { ...hostReactProps };
|
||||
|
||||
for (const [
|
||||
reactPropName,
|
||||
reactUnsupportedEventType,
|
||||
] of REACT_UNSUPPORTED_EVENT_PROP_ENTRIES) {
|
||||
const handler = reactBindableProps[reactPropName];
|
||||
delete reactBindableProps[reactPropName];
|
||||
|
||||
if (isFunction(handler)) {
|
||||
reactUnsupportedEventHandlers[reactUnsupportedEventType] = handler as (
|
||||
event: Event,
|
||||
) => void;
|
||||
}
|
||||
}
|
||||
|
||||
return { reactUnsupportedEventHandlers, reactBindableProps };
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
import { isFunction, isUndefined } from '@sniptt/guards';
|
||||
|
||||
import { EVENT_TO_REACT } from '@/constants/EventToReact';
|
||||
import { hasDangerousUrlScheme } from '@/host/utils/hasDangerousUrlScheme';
|
||||
import { isEventHandlerKey } from '@/host/utils/isEventHandlerKey';
|
||||
import { isNavigationUrlAttribute } from '@/host/utils/isNavigationUrlAttribute';
|
||||
import { parseCssString } from '@/host/utils/parseCssString';
|
||||
import { wrapEventHandler } from '@/host/utils/wrapEventHandler';
|
||||
import { type SerializedEventData } from '@/types/SerializedEventData';
|
||||
|
||||
const INTERNAL_PROPS = new Set(['element', 'receiver', 'components']);
|
||||
|
||||
const EVENT_NAME_MAP: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(EVENT_TO_REACT).map(([domEvent, reactProp]) => [
|
||||
`on${domEvent}`,
|
||||
reactProp,
|
||||
]),
|
||||
);
|
||||
|
||||
export const filterProps = <T extends object>(props: T, htmlTag: string): T => {
|
||||
const filtered: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
if (INTERNAL_PROPS.has(key) || isUndefined(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === 'style') {
|
||||
filtered.style = parseCssString(value as string | undefined);
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedKey = EVENT_NAME_MAP[key.toLowerCase()] || key;
|
||||
|
||||
if (isEventHandlerKey(normalizedKey)) {
|
||||
if (isFunction(value)) {
|
||||
filtered[normalizedKey] = wrapEventHandler(
|
||||
value as (detail: SerializedEventData) => void,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
isNavigationUrlAttribute(htmlTag, normalizedKey) &&
|
||||
hasDangerousUrlScheme(value)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
filtered[normalizedKey] = value;
|
||||
}
|
||||
|
||||
return filtered as T;
|
||||
};
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { isFunction } from '@sniptt/guards';
|
||||
|
||||
import { type RemoteEventHandler } from '@/host/types/RemoteEventHandler';
|
||||
|
||||
// A remote handler crosses the boundary asynchronously, so its own
|
||||
// preventDefault would land after the browser already acted.
|
||||
export const preventDefaultThenForwardToRemote =
|
||||
(remoteHandler: unknown) => (event: { preventDefault: () => void }) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (isFunction(remoteHandler)) {
|
||||
(remoteHandler as RemoteEventHandler)(event);
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isBoolean, isNumber, isObject, isString } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { applyFirstChangedTouchCoordinates } from '@/host/utils/applyFirstChangedTouchCoordinates';
|
||||
import { serializeFileList } from '@/host/utils/serializeFileList';
|
||||
import { type SerializedEventData } from '@/types/SerializedEventData';
|
||||
|
||||
@@ -70,6 +71,8 @@ export const serializeEvent = (event: unknown): SerializedEventData => {
|
||||
serialized.buttons = domEvent.buttons;
|
||||
}
|
||||
|
||||
applyFirstChangedTouchCoordinates(serialized, domEvent);
|
||||
|
||||
if (isNumber(domEvent.pointerId)) {
|
||||
serialized.pointerId = domEvent.pointerId;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user