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:
+2
-2
@@ -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');
|
||||
|
||||
+40
@@ -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,
|
||||
});
|
||||
+51
-1
@@ -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 }) => {
|
||||
|
||||
+55
@@ -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,
|
||||
});
|
||||
+19
@@ -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' } });
|
||||
},
|
||||
});
|
||||
|
||||
+27
@@ -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,
|
||||
});
|
||||
+3
-3
@@ -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;
|
||||
|
||||
+18
-1
@@ -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',
|
||||
+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;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<
|
||||
|
||||
Reference in New Issue
Block a user