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:
Raphaël Bosi
2026-07-20 18:24:21 +02:00
committed by GitHub
parent 4ebdecfdf0
commit eb651180aa
35 changed files with 2884 additions and 234 deletions
@@ -1,6 +1,6 @@
import type { Project, SourceFile } from 'ts-morph';
import { EVENT_TO_REACT } from '../../../src/constants/EventToReact';
import { DOM_EVENT_TYPE_TO_REACT_PROP } from '../../../src/constants/DomEventTypeToReactProp';
import { type ComponentSchema } from './schemas';
import { addExportedConst } from './utils';
@@ -15,7 +15,7 @@ const generateComponentDefinition = (
if (hasEvents) {
const eventProps = component.events
.map((event) => {
const propName = EVENT_TO_REACT[event];
const propName = DOM_EVENT_TYPE_TO_REACT_PROP[event];
return ` ${propName}: { event: '${event}' },`;
})
.join('\n');
@@ -0,0 +1,40 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { FILL_RECT_STYLE } from '@/__stories__/shared/front-components/styles';
const DivDragDropFrontComponent = () => {
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="div:drag-drop">
<div
data-testid="subject"
onDragStart={pushEvent}
onDragEnd={pushEvent}
style={FILL_RECT_STYLE}
>
drag source
</div>
<div
data-testid="drop-zone"
onDragOver={pushEvent}
onDrop={pushEvent}
style={FILL_RECT_STYLE}
>
drop zone
</div>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-div-dnd-00000000-0000-0000-0000-000000000020',
name: 'div-drag-drop-front-component',
description: 'Front component covering drag and drop events on <div>',
component: DivDragDropFrontComponent,
});
@@ -1,5 +1,5 @@
import { type Meta, type StoryObj } from '@storybook/react-vite';
import { userEvent, within } from 'storybook/test';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
@@ -73,6 +73,56 @@ export const MouseEnterLeave: Story = runFrontComponentStory({
},
});
export const DragDrop: Story = runFrontComponentStory({
frontComponentBundleName: 'div-drag-drop',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
const dropZone = await canvas.findByTestId('drop-zone');
subject.dispatchEvent(
new DragEvent('dragstart', { bubbles: true, cancelable: true }),
);
await expectEventLogged({ canvas, matcher: { type: 'dragstart' } });
await waitFor(() => {
const dragOverEvent = new DragEvent('dragover', {
bubbles: true,
cancelable: true,
});
dropZone.dispatchEvent(dragOverEvent);
expect(dragOverEvent.defaultPrevented).toBe(true);
});
await expectEventLogged({ canvas, matcher: { type: 'dragover' } });
dropZone.dispatchEvent(
new DragEvent('drop', { bubbles: true, cancelable: true }),
);
await expectEventLogged({ canvas, matcher: { type: 'drop' } });
},
});
export const FocusInOut: Story = runFrontComponentStory({
frontComponentBundleName: 'div-focus-in-out',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
await expectFrontComponentValue({ canvas, expected: 'ready' });
const subject = await canvas.findByTestId('subject');
await userEvent.click(subject);
await expectEventLogged({ canvas, matcher: { type: 'focusin' } });
subject.blur();
await expectEventLogged({ canvas, matcher: { type: 'focusout' } });
},
});
export const PointerMove: Story = runFrontComponentStory({
frontComponentBundleName: 'div-pointermove',
play: async ({ canvasElement }) => {
@@ -0,0 +1,55 @@
import { type SyntheticEvent, useEffect, useRef, useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
import { isDefined } from 'twenty-shared/utils';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
const DivFocusInOutFrontComponent = () => {
const containerRef = useRef<HTMLDivElement | null>(null);
const [isListening, setIsListening] = useState(false);
const { entries, pushEvent } = useEventLog();
useEffect(() => {
const container = containerRef.current;
if (!isDefined(container)) {
return;
}
const handleFocusEvent = (event: Event) => {
pushEvent(event as unknown as SyntheticEvent<Element>);
};
container.addEventListener('focusin', handleFocusEvent);
container.addEventListener('focusout', handleFocusEvent);
setIsListening(true);
return () => {
container.removeEventListener('focusin', handleFocusEvent);
container.removeEventListener('focusout', handleFocusEvent);
};
}, [pushEvent]);
return (
<FrontComponentCard title="div:focus-in-out">
<div data-testid="container" ref={containerRef}>
<input data-testid="subject" placeholder="focus me" />
</div>
<span data-testid="front-component-value">
{isListening ? 'ready' : 'pending'}
</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier:
'fc-div-focus-in-out-00000000-0000-0000-0000-000000000020',
name: 'div-focus-in-out-front-component',
description:
'Front component covering focusin and focusout listeners on <div>',
component: DivFocusInOutFrontComponent,
});
@@ -1,4 +1,5 @@
import { type Meta } from '@storybook/react-vite';
import { userEvent, within } from 'storybook/test';
import { FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
import {
@@ -9,6 +10,9 @@ import {
createHtmlTagClickStory,
createHtmlTagFocusStory,
} from '@/__stories__/shared/test-utils/createHtmlElementStory';
import { expectEventLogged } from '@/__stories__/shared/test-utils/matchers/expectEventLogged';
import { expectFrontComponentMounted } from '@/__stories__/shared/test-utils/matchers/expectFrontComponentMounted';
import { runFrontComponentStory } from '@/__stories__/shared/test-utils/runFrontComponentStory';
const meta: Meta<typeof FrontComponentRenderer> = {
title: 'FrontComponent/HtmlTag/Interactive/Details/Events',
@@ -27,3 +31,18 @@ export const Click = createHtmlTagClickStory({
export const FocusBlur = createHtmlTagFocusStory({
frontComponentBundleName: 'details-focus-blur',
});
export const Toggle = runFrontComponentStory({
frontComponentBundleName: 'details-toggle',
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
const summary = await canvas.findByTestId('summary');
await userEvent.click(summary);
await expectEventLogged({ canvas, matcher: { type: 'toggle' } });
},
});
@@ -0,0 +1,27 @@
import { defineFrontComponent } from 'twenty-sdk/define';
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
const DetailsToggleFrontComponent = () => {
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="details:toggle">
<details data-testid="subject" onToggle={pushEvent}>
<summary data-testid="summary">summary</summary>
details content
</details>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-details-toggle-00000000-0000-0000-0000-000000000020',
name: 'details-toggle-front-component',
description: 'Front component covering toggle on <details>',
component: DetailsToggleFrontComponent,
});
@@ -1,4 +1,4 @@
import { type SyntheticEvent, useState } from 'react';
import { type SyntheticEvent, useCallback, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
export type LoggedEventFile = {
@@ -136,7 +136,7 @@ const toUnknownRecord = (value: unknown): Record<string, unknown> =>
export const useEventLog = () => {
const [entries, setEntries] = useState<LoggedEventEntry[]>([]);
const pushEvent = (event: SyntheticEvent<Element>) => {
const pushEvent = useCallback((event: SyntheticEvent<Element>) => {
setEntries((previousEntries) => {
const eventRecord = event as unknown as Record<string, unknown>;
const target = toUnknownRecord(eventRecord.target);
@@ -254,7 +254,7 @@ export const useEventLog = () => {
return [...previousEntries, entry];
});
};
}, []);
return { entries, pushEvent };
};
@@ -50,6 +50,7 @@ export const ALLOWED_HTML_ELEMENTS: AllowedHtmlElement[] = [
width: { type: 'number', optional: true },
height: { type: 'number', optional: true },
},
events: ['load', 'error'],
},
{ tag: 'html-ul', name: 'HtmlUl', properties: {} },
{ tag: 'html-ol', name: 'HtmlOl', properties: {} },
@@ -333,6 +334,7 @@ export const ALLOWED_HTML_ELEMENTS: AllowedHtmlElement[] = [
properties: {
open: { type: 'boolean', optional: true },
},
events: ['toggle'],
},
{ tag: 'html-summary', name: 'HtmlSummary', properties: {} },
{ tag: 'html-address', name: 'HtmlAddress', properties: {} },
@@ -342,6 +344,7 @@ export const ALLOWED_HTML_ELEMENTS: AllowedHtmlElement[] = [
properties: {
open: { type: 'boolean', optional: true },
},
events: ['toggle'],
},
{ tag: 'html-hgroup', name: 'HtmlHgroup', properties: {} },
{ tag: 'html-search', name: 'HtmlSearch', properties: {} },
@@ -28,4 +28,19 @@ export const COMMON_HTML_EVENTS = [
'wheel',
'contextmenu',
'drag',
'dragstart',
'dragenter',
'dragleave',
'dragover',
'dragend',
'drop',
'touchstart',
'touchmove',
'touchend',
'touchcancel',
'focusin',
'focusout',
'animationend',
'transitionend',
'scrollend',
] as const;
@@ -1,4 +1,4 @@
export const EVENT_TO_REACT: Record<string, string> = {
export const DOM_EVENT_TYPE_TO_REACT_PROP: Record<string, string> = {
click: 'onClick',
dblclick: 'onDoubleClick',
mousedown: 'onMouseDown',
@@ -28,6 +28,23 @@ export const EVENT_TO_REACT: Record<string, string> = {
wheel: 'onWheel',
contextmenu: 'onContextMenu',
drag: 'onDrag',
dragstart: 'onDragStart',
dragenter: 'onDragEnter',
dragleave: 'onDragLeave',
dragover: 'onDragOver',
dragend: 'onDragEnd',
drop: 'onDrop',
touchstart: 'onTouchStart',
touchmove: 'onTouchMove',
touchend: 'onTouchEnd',
touchcancel: 'onTouchCancel',
focusin: 'onFocusIn',
focusout: 'onFocusOut',
animationend: 'onAnimationEnd',
transitionend: 'onTransitionEnd',
scrollend: 'onScrollEnd',
toggle: 'onToggle',
load: 'onLoad',
timeupdate: 'onTimeUpdate',
play: 'onPlay',
pause: 'onPause',
@@ -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',
};
@@ -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;
};
@@ -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;
@@ -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');
});
});
@@ -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);
@@ -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);
});
});
@@ -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' }),
);
});
});
@@ -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);
});
});
@@ -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');
});
});
@@ -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',
@@ -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;
}
}
};
@@ -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;
};
@@ -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,
);
};
@@ -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);
}
}
};
};
@@ -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;
};
@@ -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;
}
@@ -52,6 +52,21 @@ export type HtmlCommonEvents = {
wheel(event: RemoteEvent<SerializedEventData>): void;
contextmenu(event: RemoteEvent<SerializedEventData>): void;
drag(event: RemoteEvent<SerializedEventData>): void;
dragstart(event: RemoteEvent<SerializedEventData>): void;
dragenter(event: RemoteEvent<SerializedEventData>): void;
dragleave(event: RemoteEvent<SerializedEventData>): void;
dragover(event: RemoteEvent<SerializedEventData>): void;
dragend(event: RemoteEvent<SerializedEventData>): void;
drop(event: RemoteEvent<SerializedEventData>): void;
touchstart(event: RemoteEvent<SerializedEventData>): void;
touchmove(event: RemoteEvent<SerializedEventData>): void;
touchend(event: RemoteEvent<SerializedEventData>): void;
touchcancel(event: RemoteEvent<SerializedEventData>): void;
focusin(event: RemoteEvent<SerializedEventData>): void;
focusout(event: RemoteEvent<SerializedEventData>): void;
animationend(event: RemoteEvent<SerializedEventData>): void;
transitionend(event: RemoteEvent<SerializedEventData>): void;
scrollend(event: RemoteEvent<SerializedEventData>): void;
};
const HTML_COMMON_EVENTS_ARRAY = [
@@ -84,6 +99,21 @@ const HTML_COMMON_EVENTS_ARRAY = [
'wheel',
'contextmenu',
'drag',
'dragstart',
'dragenter',
'dragleave',
'dragover',
'dragend',
'drop',
'touchstart',
'touchmove',
'touchend',
'touchcancel',
'focusin',
'focusout',
'animationend',
'transitionend',
'scrollend',
] as const;
const createSerializedEventConfig = (
eventType: string,
@@ -401,7 +431,10 @@ export const HtmlImgElement = createRemoteElement<
HtmlImgProperties,
Record<string, never>,
Record<string, never>,
HtmlCommonEvents
HtmlCommonEvents & {
load(event: RemoteEvent<SerializedEventData>): void;
error(event: RemoteEvent<SerializedEventData>): void;
}
>({
properties: {
...HTML_COMMON_PROPERTIES_CONFIG,
@@ -412,6 +445,8 @@ export const HtmlImgElement = createRemoteElement<
},
events: {
...HTML_COMMON_EVENTS_CONFIG,
load: createSerializedEventConfig('load'),
error: createSerializedEventConfig('error'),
},
});
export const HtmlUlElement = createRemoteElement<
@@ -1332,7 +1367,7 @@ export const HtmlDetailsElement = createRemoteElement<
HtmlDetailsProperties,
Record<string, never>,
Record<string, never>,
HtmlCommonEvents
HtmlCommonEvents & { toggle(event: RemoteEvent<SerializedEventData>): void }
>({
properties: {
...HTML_COMMON_PROPERTIES_CONFIG,
@@ -1340,6 +1375,7 @@ export const HtmlDetailsElement = createRemoteElement<
},
events: {
...HTML_COMMON_EVENTS_CONFIG,
toggle: createSerializedEventConfig('toggle'),
},
});
export const HtmlSummaryElement = createRemoteElement<
@@ -1373,7 +1409,7 @@ export const HtmlDialogElement = createRemoteElement<
HtmlDialogProperties,
Record<string, never>,
Record<string, never>,
HtmlCommonEvents
HtmlCommonEvents & { toggle(event: RemoteEvent<SerializedEventData>): void }
>({
properties: {
...HTML_COMMON_PROPERTIES_CONFIG,
@@ -1381,6 +1417,7 @@ export const HtmlDialogElement = createRemoteElement<
},
events: {
...HTML_COMMON_EVENTS_CONFIG,
toggle: createSerializedEventConfig('toggle'),
},
});
export const HtmlHgroupElement = createRemoteElement<