Files
twenty/packages/twenty-sdk/src/front-component-renderer/remote/components/FrontComponentWorkerEffect.tsx
T
Raphaël Bosi abdab2fb7e [Command menu items] Create engine commands (#18681)
## Description

- Introduces a new engine command execution model that replaces the
previous approach of mapping `EngineComponentKey` to React components.
Instead, engine commands are now mounted headlessly via
`HeadlessEngineCommandMountRoot`, with their execution context populated
synchronously before mounting.
- Creates new headless command components
- Moves error handling from the SDK layer to the host app by wrapping
all mounted commands with a new `CommandMenuItemErrorBoundary`

The new flow works as follows:
- When a command menu item with an `engineComponentKey` is clicked,
`useCommandMenuItemFrontComponentCommands` calls
`useMountEngineCommand`, which synchronously reads the current context
store (object metadata, selected records, filters, view ID, etc.) and
writes a `MountedEngineCommandContext` into
`mountedEngineCommandsState`.
- The command is then mounted into `mountedEngineCommandsState`, which
triggers `HeadlessEngineCommandMountRoot` to render the corresponding
headless component from `ENGINE_COMPONENT_KEY_HEADLESS_COMPONENT_MAP`,
wrapped in `CommandMenuItemErrorBoundary`,
`ContextStoreComponentInstanceContext.Provider`, and
`EngineCommandComponentInstanceContext.Provider`.
- Each command component reads its execution context and delegates to
one of the 4 execution patterns: `HeadlessEngineCommandWrapperEffect`
(simple actions), `HeadlessConfirmationModalEngineCommandEffect`
(destructive actions needing confirmation),
`HeadlessNavigateEngineCommand` (GO_TO_* commands), or
`HeadlessOpenSidePanelPageEngineCommand` (SEARCH_RECORDS, ASK_AI,
VIEW_PREVIOUS_AI_CHATS).
- After execution, the command self-unmounts via
`useUnmountEngineCommand`, which removes the entry from
`mountedEngineCommandsState` and stops rendering the component.
2026-03-17 17:25:18 +01:00

138 lines
4.0 KiB
TypeScript

import { ThreadWebWorker, release, retain } from '@quilted/threads';
import { RemoteReceiver } from '@remote-dom/core/receivers';
import { useEffect, useRef } from 'react';
import { type ConfirmationModalCaller } from 'twenty-shared/types';
import { type CommandConfirmationModalResult } from '../../../sdk/front-component-api/globals/frontComponentHostCommunicationApi';
import { type FrontComponentHostCommunicationApi } from '../../types/FrontComponentHostCommunicationApi';
import { type WorkerExports } from '../../types/WorkerExports';
import { createRemoteWorker } from '../worker/utils/createRemoteWorker';
// Must match COMMAND_MENU_ITEM_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME in twenty-front
const COMMAND_MENU_ITEM_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME =
'command-menu-item-confirmation-modal-result';
type CommandMenuItemConfirmationModalResultBrowserEventDetail = {
caller: ConfirmationModalCaller;
confirmationResult: CommandConfirmationModalResult;
};
type FrontComponentWorkerEffectProps = {
componentUrl: string;
applicationAccessToken?: string;
apiUrl?: string;
frontComponentId: string;
frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi;
setReceiver: React.Dispatch<React.SetStateAction<RemoteReceiver | null>>;
setThread: React.Dispatch<
React.SetStateAction<ThreadWebWorker<
WorkerExports,
FrontComponentHostCommunicationApi
> | null>
>;
setError: React.Dispatch<React.SetStateAction<Error | null>>;
};
export const FrontComponentWorkerEffect = ({
componentUrl,
applicationAccessToken,
apiUrl,
frontComponentId,
frontComponentHostCommunicationApi,
setReceiver,
setThread,
setError,
}: FrontComponentWorkerEffectProps) => {
const isInitializedRef = useRef(false);
useEffect(() => {
if (isInitializedRef.current) {
return;
}
const newReceiver = new RemoteReceiver({ retain, release });
const worker = createRemoteWorker();
worker.onerror = (event: ErrorEvent) => {
const workerError =
event.error ?? new Error(event.message || 'Unknown worker error');
console.error('[FrontComponentRenderer] Worker error:', workerError);
setError(workerError);
};
const thread = new ThreadWebWorker<
WorkerExports,
FrontComponentHostCommunicationApi
>(worker, {
exports: frontComponentHostCommunicationApi,
});
const handleCommandMenuItemConfirmationModalResultBrowserEvent = (
event: CustomEvent<CommandMenuItemConfirmationModalResultBrowserEventDetail>,
) => {
const commandMenuItemConfirmationModalResultBrowserEventDetail =
event.detail;
const caller =
commandMenuItemConfirmationModalResultBrowserEventDetail.caller;
if (
caller.type !== 'frontComponent' ||
caller.frontComponentId !== frontComponentId
) {
return;
}
thread.imports
.onConfirmationModalResult(
commandMenuItemConfirmationModalResultBrowserEventDetail.confirmationResult,
)
.catch((error: Error) => {
setError(error);
});
};
window.addEventListener(
COMMAND_MENU_ITEM_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME,
handleCommandMenuItemConfirmationModalResultBrowserEvent as EventListener,
);
setThread(thread);
thread.imports
.render(newReceiver.connection, {
componentUrl,
applicationAccessToken,
apiUrl,
})
.catch((error: Error) => {
setError(error);
});
setReceiver(newReceiver);
isInitializedRef.current = true;
return () => {
window.removeEventListener(
COMMAND_MENU_ITEM_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME,
handleCommandMenuItemConfirmationModalResultBrowserEvent as EventListener,
);
setThread(null);
worker.terminate();
isInitializedRef.current = false;
};
}, [
componentUrl,
applicationAccessToken,
apiUrl,
frontComponentId,
setError,
setReceiver,
setThread,
frontComponentHostCommunicationApi,
]);
return null;
};