Files
twenty/packages/twenty-front-component-renderer/src/host/utils/__tests__/buildHostReactPropsFromRemoteProps.test.ts
T
Raphaël Bosi eb651180aa 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. -->
2026-07-20 18:24:21 +02:00

194 lines
5.6 KiB
TypeScript

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');
});
});