[FRONT COMPONENTS] Serialize events through the worker boundary (#17767)

- Introduces a SerializedEventData type that captures only serializable
properties from DOM/React events
- Updates `wrapEventHandler` in the host component registry to serialize
native events via serializeEvent() before passing them across the worker
boundary via postMessage, avoiding circular references and non-cloneable
DOM nodes
- Updates generated remote element event signatures to use
RemoteEvent<SerializedEventData> instead of bare RemoteEvent, giving
front-component authors typed access to event details
This commit is contained in:
Raphaël Bosi
2026-02-09 16:22:54 +01:00
committed by GitHub
parent c18726d712
commit c6d04ccced
7 changed files with 189 additions and 31 deletions
@@ -20,6 +20,7 @@ var TestComponent = () => {
}
);
};
var test_component_front_component_default = globalThis.jsx(TestComponent, {});
export {
test_component_front_component_default as default
@@ -40,9 +40,61 @@ const parseStyle = (styleString: string | undefined): React.CSSProperties | unde
return style;
};
const wrapEventHandler = (handler: () => void) => {
return (_event: unknown) => {
handler();
const serializeEvent = (event: unknown): SerializedEventData => {
if (!event || typeof event !== 'object') {
return { type: 'unknown' };
}
const domEvent = event as Record<string, unknown>;
const serialized: SerializedEventData = {
type: typeof domEvent.type === 'string' ? domEvent.type : 'unknown',
};
if ('altKey' in domEvent) serialized.altKey = domEvent.altKey as boolean;
if ('ctrlKey' in domEvent) serialized.ctrlKey = domEvent.ctrlKey as boolean;
if ('metaKey' in domEvent) serialized.metaKey = domEvent.metaKey as boolean;
if ('shiftKey' in domEvent) serialized.shiftKey = domEvent.shiftKey as boolean;
if ('clientX' in domEvent) serialized.clientX = domEvent.clientX as number;
if ('clientY' in domEvent) serialized.clientY = domEvent.clientY as number;
if ('pageX' in domEvent) serialized.pageX = domEvent.pageX as number;
if ('pageY' in domEvent) serialized.pageY = domEvent.pageY as number;
if ('screenX' in domEvent) serialized.screenX = domEvent.screenX as number;
if ('screenY' in domEvent) serialized.screenY = domEvent.screenY as number;
if ('button' in domEvent) serialized.button = domEvent.button as number;
if ('buttons' in domEvent) serialized.buttons = domEvent.buttons as number;
if ('key' in domEvent) serialized.key = domEvent.key as string;
if ('code' in domEvent) serialized.code = domEvent.code as string;
if ('repeat' in domEvent) serialized.repeat = domEvent.repeat as boolean;
if ('deltaX' in domEvent) serialized.deltaX = domEvent.deltaX as number;
if ('deltaY' in domEvent) serialized.deltaY = domEvent.deltaY as number;
if ('deltaZ' in domEvent) serialized.deltaZ = domEvent.deltaZ as number;
if ('deltaMode' in domEvent) serialized.deltaMode = domEvent.deltaMode as number;
const target = domEvent.target as Record<string, unknown> | undefined;
if (target && typeof target === 'object') {
if ('value' in target && typeof target.value === 'string') {
serialized.value = target.value;
}
if ('checked' in target && typeof target.checked === 'boolean') {
serialized.checked = target.checked;
}
if ('scrollTop' in target && typeof target.scrollTop === 'number') {
serialized.scrollTop = target.scrollTop;
}
if ('scrollLeft' in target && typeof target.scrollLeft === 'number') {
serialized.scrollLeft = target.scrollLeft;
}
}
return serialized;
};
const wrapEventHandler = (handler: (detail: SerializedEventData) => void) => {
return (event: unknown) => {
handler(serializeEvent(event));
};
};
@@ -56,7 +108,7 @@ const filterProps = (props: Record<string, unknown>) => {
} else {
const normalizedKey = EVENT_NAME_MAP[key.toLowerCase()] || key;
if (normalizedKey.startsWith('on') && typeof value === 'function') {
filtered[normalizedKey] = wrapEventHandler(value as () => void);
filtered[normalizedKey] = wrapEventHandler(value as (detail: SerializedEventData) => void);
} else {
filtered[normalizedKey] = value;
}
@@ -172,6 +224,11 @@ export const generateHostRegistry = (
namedImports: ['RemoteFragmentRenderer', 'createRemoteComponentRenderer'],
});
sourceFile.addImportDeclaration({
moduleSpecifier: '../../../sdk/front-component-common/SerializedEventData',
namedImports: [{ name: 'SerializedEventData', isTypeOnly: true }],
});
const uiImports = groupImportsByPath(components);
for (const [modulePath, namedImports] of uiImports) {
@@ -84,7 +84,9 @@ const generateCommonEventsType = (
type: (writer) => {
writer.block(() => {
for (const event of events) {
writer.writeLine(`${event}(event: RemoteEvent): void;`);
writer.writeLine(
`${event}(event: RemoteEvent<SerializedEventData>): void;`,
);
}
});
},
@@ -184,7 +186,7 @@ const generateElementDefinition = (
const eventsType = hasEvents
? useSharedEvents
? TYPE_NAMES.COMMON_EVENTS
: `{ ${component.events.map((event) => `${event}(event: RemoteEvent): void`).join('; ')} }`
: `{ ${component.events.map((event) => `${event}(event: RemoteEvent<SerializedEventData>): void`).join('; ')} }`
: TYPE_NAMES.EMPTY_RECORD;
sourceFile.addVariableStatement({
@@ -346,6 +348,11 @@ export const generateRemoteElements = (
],
});
sourceFile.addImportDeclaration({
moduleSpecifier: '../../../sdk/front-component-common/SerializedEventData',
namedImports: [{ name: 'SerializedEventData', isTypeOnly: true }],
});
const commonPropertyNames = new Set(Object.keys(commonProperties));
const componentsWithProps = prepareComponentsWithSpecificProps(
components,
@@ -7,12 +7,13 @@
* |___/
*/
import React from 'react';
import {
RemoteFragmentRenderer,
createRemoteComponentRenderer,
} from '@remote-dom/react/host';
import React from 'react';
import { Button } from 'twenty-ui/input';
import { type SerializedEventData } from '../../../sdk/front-component-common/SerializedEventData';
const INTERNAL_PROPS = new Set(['element', 'receiver', 'components']);
const EVENT_NAME_MAP: Record<string, string> = {
@@ -64,9 +65,71 @@ const parseStyle = (
return style;
};
const wrapEventHandler = (handler: () => void) => {
return (_event: unknown) => {
handler();
// Extracts only serializable properties from DOM/React events.
// Native events contain circular references and DOM nodes that cannot
// cross the worker boundary via postMessage.
const serializeEvent = (event: unknown): SerializedEventData => {
if (!event || typeof event !== 'object') {
return { type: 'unknown' };
}
const domEvent = event as Record<string, unknown>;
const serialized: SerializedEventData = {
type: typeof domEvent.type === 'string' ? domEvent.type : 'unknown',
};
// Modifier keys (shared by mouse and keyboard events)
if ('altKey' in domEvent) serialized.altKey = domEvent.altKey as boolean;
if ('ctrlKey' in domEvent) serialized.ctrlKey = domEvent.ctrlKey as boolean;
if ('metaKey' in domEvent) serialized.metaKey = domEvent.metaKey as boolean;
if ('shiftKey' in domEvent)
serialized.shiftKey = domEvent.shiftKey as boolean;
// Mouse event properties
if ('clientX' in domEvent) serialized.clientX = domEvent.clientX as number;
if ('clientY' in domEvent) serialized.clientY = domEvent.clientY as number;
if ('pageX' in domEvent) serialized.pageX = domEvent.pageX as number;
if ('pageY' in domEvent) serialized.pageY = domEvent.pageY as number;
if ('screenX' in domEvent) serialized.screenX = domEvent.screenX as number;
if ('screenY' in domEvent) serialized.screenY = domEvent.screenY as number;
if ('button' in domEvent) serialized.button = domEvent.button as number;
if ('buttons' in domEvent) serialized.buttons = domEvent.buttons as number;
// Keyboard event properties
if ('key' in domEvent) serialized.key = domEvent.key as string;
if ('code' in domEvent) serialized.code = domEvent.code as string;
if ('repeat' in domEvent) serialized.repeat = domEvent.repeat as boolean;
// Wheel event properties
if ('deltaX' in domEvent) serialized.deltaX = domEvent.deltaX as number;
if ('deltaY' in domEvent) serialized.deltaY = domEvent.deltaY as number;
if ('deltaZ' in domEvent) serialized.deltaZ = domEvent.deltaZ as number;
if ('deltaMode' in domEvent)
serialized.deltaMode = domEvent.deltaMode as number;
// Extract value/checked from target (for input/change/select events)
const target = domEvent.target as Record<string, unknown> | undefined;
if (target && typeof target === 'object') {
if ('value' in target && typeof target.value === 'string') {
serialized.value = target.value;
}
if ('checked' in target && typeof target.checked === 'boolean') {
serialized.checked = target.checked;
}
if ('scrollTop' in target && typeof target.scrollTop === 'number') {
serialized.scrollTop = target.scrollTop;
}
if ('scrollLeft' in target && typeof target.scrollLeft === 'number') {
serialized.scrollLeft = target.scrollLeft;
}
}
return serialized;
};
const wrapEventHandler = (handler: (detail: SerializedEventData) => void) => {
return (event: unknown) => {
handler(serializeEvent(event));
};
};
@@ -80,7 +143,9 @@ const filterProps = (props: Record<string, unknown>) => {
} else {
const normalizedKey = EVENT_NAME_MAP[key.toLowerCase()] || key;
if (normalizedKey.startsWith('on') && typeof value === 'function') {
filtered[normalizedKey] = wrapEventHandler(value as () => void);
filtered[normalizedKey] = wrapEventHandler(
value as (detail: SerializedEventData) => void,
);
} else {
filtered[normalizedKey] = value;
}
@@ -13,6 +13,7 @@ import {
RemoteFragmentElement,
type RemoteEvent,
} from '@remote-dom/core/elements';
import { type SerializedEventData } from '../../../sdk/front-component-common/SerializedEventData';
export type HtmlCommonProperties = {
id?: string;
@@ -26,26 +27,26 @@ export type HtmlCommonProperties = {
'data-testid'?: string;
};
export type HtmlCommonEvents = {
click(event: RemoteEvent): void;
dblclick(event: RemoteEvent): void;
mousedown(event: RemoteEvent): void;
mouseup(event: RemoteEvent): void;
mouseover(event: RemoteEvent): void;
mouseout(event: RemoteEvent): void;
mouseenter(event: RemoteEvent): void;
mouseleave(event: RemoteEvent): void;
keydown(event: RemoteEvent): void;
keyup(event: RemoteEvent): void;
keypress(event: RemoteEvent): void;
focus(event: RemoteEvent): void;
blur(event: RemoteEvent): void;
change(event: RemoteEvent): void;
input(event: RemoteEvent): void;
submit(event: RemoteEvent): void;
scroll(event: RemoteEvent): void;
wheel(event: RemoteEvent): void;
contextmenu(event: RemoteEvent): void;
drag(event: RemoteEvent): void;
click(event: RemoteEvent<SerializedEventData>): void;
dblclick(event: RemoteEvent<SerializedEventData>): void;
mousedown(event: RemoteEvent<SerializedEventData>): void;
mouseup(event: RemoteEvent<SerializedEventData>): void;
mouseover(event: RemoteEvent<SerializedEventData>): void;
mouseout(event: RemoteEvent<SerializedEventData>): void;
mouseenter(event: RemoteEvent<SerializedEventData>): void;
mouseleave(event: RemoteEvent<SerializedEventData>): void;
keydown(event: RemoteEvent<SerializedEventData>): void;
keyup(event: RemoteEvent<SerializedEventData>): void;
keypress(event: RemoteEvent<SerializedEventData>): void;
focus(event: RemoteEvent<SerializedEventData>): void;
blur(event: RemoteEvent<SerializedEventData>): void;
change(event: RemoteEvent<SerializedEventData>): void;
input(event: RemoteEvent<SerializedEventData>): void;
submit(event: RemoteEvent<SerializedEventData>): void;
scroll(event: RemoteEvent<SerializedEventData>): void;
wheel(event: RemoteEvent<SerializedEventData>): void;
contextmenu(event: RemoteEvent<SerializedEventData>): void;
drag(event: RemoteEvent<SerializedEventData>): void;
};
const HTML_COMMON_EVENTS_ARRAY = [
@@ -0,0 +1,26 @@
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;
button?: number;
buttons?: number;
key?: string;
code?: string;
repeat?: boolean;
value?: string;
checked?: boolean;
scrollTop?: number;
scrollLeft?: number;
deltaX?: number;
deltaY?: number;
deltaZ?: number;
deltaMode?: number;
};
@@ -15,3 +15,4 @@ export { COMMON_HTML_EVENTS } from './CommonHtmlEvents';
export { EVENT_TO_REACT } from './EventToReact';
export { HTML_COMMON_PROPERTIES } from './HtmlCommonProperties';
export { HTML_TAG_TO_REMOTE_COMPONENT } from './HtmlTagToRemoteComponent';
export type { SerializedEventData } from './SerializedEventData';