Fix front component pointer/mouse event coordinates (#21117)

Fixes https://github.com/twentyhq/twenty/issues/21000

Front-component event handlers read standard event fields
(event.clientX, event.offsetX, …), but these were always undefined. On
the remote side, serialized event data was passed only as the
CustomEvent's detail — and CustomEvent ignores every constructor option
except detail, so the values lived at event.detail.clientX and never on
the event object itself.

- Added `applySerializedEventProperties`, to copy a curated allowlist of
event-level keys onto the event. Element/target state (value, checked,
files, scroll, media props) stays in
`applySerializedEventTargetProperties`, applied to this (the dispatch
element = event.target).
- Added x/y to `SerializedEventData` and to host-side serialization in
`createHtmlHostWrapper`.
- Added an `svg-pointer `story + `createHtmlTagPointerStory`

Note: Also pinned @types/react to v18 so the renderer stops dragging in
React 19 types and breaking typecheck.
This commit is contained in:
Raphaël Bosi
2026-06-02 17:04:11 +02:00
committed by GitHub
parent 1642be86f5
commit 1833fa84a5
13 changed files with 326 additions and 125 deletions
@@ -129,11 +129,22 @@ const generateCommonEventsType = (
});
writer.writeLine(');');
writer.blankLine();
writer.writeLine('return new CustomEvent(eventType, {');
writer.writeLine('const event = new CustomEvent(eventType, {');
writer.indent(() => {
writer.writeLine('detail: eventData,');
});
writer.writeLine('}) as RemoteEvent<SerializedEventData>;');
writer.blankLine();
writer.writeLine('applySerializedEventProperties(');
writer.indent(() => {
writer.writeLine(
'event as unknown as Record<string, unknown>,',
);
writer.writeLine('eventData,');
});
writer.writeLine(');');
writer.blankLine();
writer.writeLine('return event;');
});
writer.writeLine('},');
});
@@ -400,11 +411,18 @@ export const generateRemoteElements = (
});
sourceFile.addImportDeclaration({
moduleSpecifier: '@/constants/SerializedEventData',
namedImports: [
'applySerializedEventTargetProperties',
{ name: 'SerializedEventData', isTypeOnly: true },
],
moduleSpecifier: '@/constants/applySerializedEventProperties',
namedImports: ['applySerializedEventProperties'],
});
sourceFile.addImportDeclaration({
moduleSpecifier: '@/constants/applySerializedEventTargetProperties',
namedImports: ['applySerializedEventTargetProperties'],
});
sourceFile.addImportDeclaration({
moduleSpecifier: '@/types/SerializedEventData',
namedImports: [{ name: 'SerializedEventData', isTypeOnly: true }],
});
const commonPropertyNames = new Set(Object.keys(commonProperties));
@@ -8,6 +8,7 @@ import {
import {
createHtmlTagClickStory,
createHtmlTagFocusStory,
createHtmlTagPointerStory,
} from '@/__stories__/shared/test-utils/createHtmlElementStory';
const meta: Meta<typeof FrontComponentRenderer> = {
@@ -27,3 +28,7 @@ export const Click = createHtmlTagClickStory({
export const FocusBlur = createHtmlTagFocusStory({
frontComponentBundleName: 'svg-focus-blur',
});
export const Pointer = createHtmlTagPointerStory({
frontComponentBundleName: 'svg-pointer',
});
@@ -0,0 +1,45 @@
import {
EventLog,
useEventLog,
} from '@/__stories__/shared/front-components/event-log';
import { FrontComponentCard } from '@/__stories__/shared/front-components/front-component-card';
import { SVG_ROOT_STYLE } from '@/__stories__/shared/front-components/styles';
import { useState } from 'react';
import { defineFrontComponent } from 'twenty-sdk/define';
const SvgPointerFrontComponent = () => {
const [interactionCount, setInteractionCount] = useState(0);
const [pointerCoordinates, setPointerCoordinates] = useState('');
const { entries, pushEvent } = useEventLog();
return (
<FrontComponentCard title="svg:pointer">
<svg
data-testid="subject"
viewBox="0 0 200 120"
onPointerDown={(event) => {
setInteractionCount((previous) => previous + 1);
setPointerCoordinates(`${event.clientX},${event.clientY}`);
pushEvent(event);
}}
onPointerMove={(event) => {
pushEvent(event);
}}
tabIndex={0}
style={SVG_ROOT_STYLE}
>
<rect x="20" y="20" width="160" height="80" fill="#2563eb" />
</svg>
<span data-testid="front-component-value">{interactionCount}</span>
<span data-testid="pointer-coordinates">{pointerCoordinates}</span>
<EventLog entries={entries} />
</FrontComponentCard>
);
};
export default defineFrontComponent({
universalIdentifier: 'fc-svg-c-pointer-0000000-0000-0000-0000-000000000021',
name: 'svg-pointer-front-component',
description: 'Front component covering pointer events on <svg>',
component: SvgPointerFrontComponent,
});
@@ -24,6 +24,12 @@ export type LoggedEventEntry = {
scrollLeft?: number;
deltaX?: number;
deltaY?: number;
clientX?: number;
clientY?: number;
offsetX?: number;
offsetY?: number;
movementX?: number;
movementY?: number;
};
const EVENT_LOG_STYLE = {
@@ -216,6 +222,36 @@ export const useEventLog = () => {
entry.deltaY = deltaY;
}
const clientX = pickFromRecords(records, 'clientX', isNumberValue);
if (isDefined(clientX)) {
entry.clientX = clientX;
}
const clientY = pickFromRecords(records, 'clientY', isNumberValue);
if (isDefined(clientY)) {
entry.clientY = clientY;
}
const offsetX = pickFromRecords(records, 'offsetX', isNumberValue);
if (isDefined(offsetX)) {
entry.offsetX = offsetX;
}
const offsetY = pickFromRecords(records, 'offsetY', isNumberValue);
if (isDefined(offsetY)) {
entry.offsetY = offsetY;
}
const movementX = pickFromRecords(records, 'movementX', isNumberValue);
if (isDefined(movementX)) {
entry.movementX = movementX;
}
const movementY = pickFromRecords(records, 'movementY', isNumberValue);
if (isDefined(movementY)) {
entry.movementY = movementY;
}
return [...previousEntries, entry];
});
};
@@ -1,10 +1,10 @@
import { type StoryObj } from '@storybook/react-vite';
import { userEvent, within } from 'storybook/test';
import { expect, fireEvent, userEvent, waitFor, within } from 'storybook/test';
import { type FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
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';
import { type FrontComponentRenderer } from '@/host/components/FrontComponentRenderer';
type Story = StoryObj<typeof FrontComponentRenderer>;
@@ -30,6 +30,33 @@ export const createHtmlTagClickStory = ({
},
});
export const createHtmlTagPointerStory = ({
frontComponentBundleName,
}: CreateHtmlTagStoryParams): Story =>
runFrontComponentStory({
frontComponentBundleName,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectFrontComponentMounted(canvas);
const subject = await canvas.findByTestId('subject');
fireEvent.pointerDown(subject, { clientX: 123, clientY: 45 });
await expectEventLogged({
canvas,
matcher: { type: 'pointerdown', clientX: 123, clientY: 45 },
});
await waitFor(() => {
const coordinates = canvas.getByTestId('pointer-coordinates');
expect(coordinates.textContent).toBe('123,45');
});
},
});
export const createHtmlTagFocusStory = ({
frontComponentBundleName,
}: CreateHtmlTagStoryParams): Story =>
@@ -17,6 +17,12 @@ type LoggedEventMatcher = {
ctrlKey?: boolean;
metaKey?: boolean;
altKey?: boolean;
clientX?: number;
clientY?: number;
offsetX?: number;
offsetY?: number;
movementX?: number;
movementY?: number;
files?: { name?: string; type?: string }[];
};
@@ -1,108 +0,0 @@
export type SerializedFileData = {
name: string;
size: number;
type: string;
lastModified: number;
};
export type SerializedEventData = {
type: string;
altKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
shiftKey?: boolean;
clientX?: number;
clientY?: number;
pageX?: number;
pageY?: number;
screenX?: number;
screenY?: number;
offsetX?: number;
offsetY?: number;
movementX?: number;
movementY?: number;
button?: number;
buttons?: number;
pointerId?: number;
pointerType?: string;
pressure?: number;
tangentialPressure?: number;
tiltX?: number;
tiltY?: number;
twist?: number;
width?: number;
height?: number;
isPrimary?: boolean;
key?: string;
code?: string;
repeat?: boolean;
value?: string;
checked?: boolean;
scrollTop?: number;
scrollLeft?: number;
deltaX?: number;
deltaY?: number;
deltaZ?: number;
deltaMode?: number;
currentTime?: number;
duration?: number;
paused?: boolean;
ended?: boolean;
volume?: number;
muted?: boolean;
playbackRate?: number;
files?: SerializedFileData[];
};
export const applySerializedEventTargetProperties = (
element: Record<string, unknown>,
eventData: SerializedEventData,
): void => {
if ('value' in eventData) {
element.value = eventData.value;
}
if ('checked' in eventData) {
element.checked = eventData.checked;
}
if ('files' in eventData) {
element.files = eventData.files;
}
if ('scrollTop' in eventData) {
element.scrollTop = eventData.scrollTop;
}
if ('scrollLeft' in eventData) {
element.scrollLeft = eventData.scrollLeft;
}
if ('currentTime' in eventData) {
element.currentTime = eventData.currentTime;
}
if ('duration' in eventData) {
element.duration = eventData.duration;
}
if ('paused' in eventData) {
element.paused = eventData.paused;
}
if ('ended' in eventData) {
element.ended = eventData.ended;
}
if ('volume' in eventData) {
element.volume = eventData.volume;
}
if ('muted' in eventData) {
element.muted = eventData.muted;
}
if ('playbackRate' in eventData) {
element.playbackRate = eventData.playbackRate;
}
};
@@ -0,0 +1,50 @@
import { type SerializedEventData } from '@/types/SerializedEventData';
const SERIALIZED_EVENT_PROPERTY_KEYS = [
'altKey',
'ctrlKey',
'metaKey',
'shiftKey',
'clientX',
'clientY',
'x',
'y',
'pageX',
'pageY',
'screenX',
'screenY',
'offsetX',
'offsetY',
'movementX',
'movementY',
'button',
'buttons',
'pointerId',
'pointerType',
'pressure',
'tangentialPressure',
'tiltX',
'tiltY',
'twist',
'width',
'height',
'isPrimary',
'key',
'code',
'repeat',
'deltaX',
'deltaY',
'deltaZ',
'deltaMode',
] as const satisfies readonly (keyof SerializedEventData)[];
export const applySerializedEventProperties = (
event: Record<string, unknown>,
eventData: SerializedEventData,
): void => {
for (const key of SERIALIZED_EVENT_PROPERTY_KEYS) {
if (key in eventData) {
event[key] = eventData[key];
}
}
};
@@ -0,0 +1,54 @@
import { type SerializedEventData } from '@/types/SerializedEventData';
export const applySerializedEventTargetProperties = (
element: Record<string, unknown>,
eventData: SerializedEventData,
): void => {
if ('value' in eventData) {
element.value = eventData.value;
}
if ('checked' in eventData) {
element.checked = eventData.checked;
}
if ('files' in eventData) {
element.files = eventData.files;
}
if ('scrollTop' in eventData) {
element.scrollTop = eventData.scrollTop;
}
if ('scrollLeft' in eventData) {
element.scrollLeft = eventData.scrollLeft;
}
if ('currentTime' in eventData) {
element.currentTime = eventData.currentTime;
}
if ('duration' in eventData) {
element.duration = eventData.duration;
}
if ('paused' in eventData) {
element.paused = eventData.paused;
}
if ('ended' in eventData) {
element.ended = eventData.ended;
}
if ('volume' in eventData) {
element.volume = eventData.volume;
}
if ('muted' in eventData) {
element.muted = eventData.muted;
}
if ('playbackRate' in eventData) {
element.playbackRate = eventData.playbackRate;
}
};
@@ -11,10 +11,8 @@ import React, { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { EVENT_TO_REACT } from '@/constants/EventToReact';
import {
type SerializedEventData,
type SerializedFileData,
} from '@/constants/SerializedEventData';
import { type SerializedEventData } from '@/types/SerializedEventData';
import { type SerializedFileData } from '@/types/SerializedFileData';
import {
FrontComponentInputFocusContext,
type SetEditableFocused,
@@ -145,6 +143,12 @@ const serializeEvent = (event: unknown): SerializedEventData => {
if (isNumber(domEvent.clientY)) {
serialized.clientY = domEvent.clientY;
}
if (isNumber(domEvent.x)) {
serialized.x = domEvent.x;
}
if (isNumber(domEvent.y)) {
serialized.y = domEvent.y;
}
if (isNumber(domEvent.pageX)) {
serialized.pageX = domEvent.pageX;
}
@@ -6,10 +6,9 @@ import {
type RemoteElementEventListenerDefinition,
type RemoteElementEventListenersDefinition,
} from '@remote-dom/core/elements';
import {
applySerializedEventTargetProperties,
type SerializedEventData,
} from '@/constants/SerializedEventData';
import { applySerializedEventProperties } from '@/constants/applySerializedEventProperties';
import { applySerializedEventTargetProperties } from '@/constants/applySerializedEventTargetProperties';
import { type SerializedEventData } from '@/types/SerializedEventData';
export type HtmlCommonProperties = {
id?: string;
@@ -94,9 +93,16 @@ const createSerializedEventConfig = (
eventData,
);
return new CustomEvent(eventType, {
const event = new CustomEvent(eventType, {
detail: eventData,
}) as RemoteEvent<SerializedEventData>;
applySerializedEventProperties(
event as unknown as Record<string, unknown>,
eventData,
);
return event;
},
});
const HTML_COMMON_EVENTS_CONFIG = Object.fromEntries(
@@ -0,0 +1,52 @@
import { type SerializedFileData } from '@/types/SerializedFileData';
export type SerializedEventData = {
type: string;
altKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
shiftKey?: boolean;
clientX?: number;
clientY?: number;
x?: number;
y?: number;
pageX?: number;
pageY?: number;
screenX?: number;
screenY?: number;
offsetX?: number;
offsetY?: number;
movementX?: number;
movementY?: number;
button?: number;
buttons?: number;
pointerId?: number;
pointerType?: string;
pressure?: number;
tangentialPressure?: number;
tiltX?: number;
tiltY?: number;
twist?: number;
width?: number;
height?: number;
isPrimary?: boolean;
key?: string;
code?: string;
repeat?: boolean;
value?: string;
checked?: boolean;
scrollTop?: number;
scrollLeft?: number;
deltaX?: number;
deltaY?: number;
deltaZ?: number;
deltaMode?: number;
currentTime?: number;
duration?: number;
paused?: boolean;
ended?: boolean;
volume?: number;
muted?: boolean;
playbackRate?: number;
files?: SerializedFileData[];
};
@@ -0,0 +1,6 @@
export type SerializedFileData = {
name: string;
size: number;
type: string;
lastModified: number;
};