[FRONT COMPONENTS] Headless components (#18096)

## Description

- Add `isHeadless` field to `FrontComponent` entity so front components
can run without rendering UI in the command menu
- Introduce headless front component mounting logic:
`HeadlessFrontComponentMountRoot` at the application root,
`useMountHeadlessFrontComponent`, and `useUnmountHeadlessFrontComponent`
hooks to mount/unmount headless components
- Expand the SDK with new action components (`Action`, `ActionLink`,
`ActionOpenSidePanelPage`) and host communication functions
(`openSidePanelPage`, `unmountFrontComponent`)
- Move `CommandMenuPages` type to twenty-shared so the SDK can reference
it for side panel navigation

## Video QA



https://github.com/user-attachments/assets/4f9e3bb1-fcd1-42be-b3f4-a97e80c2add2
This commit is contained in:
Raphaël Bosi
2026-02-20 15:14:42 +01:00
committed by GitHub
parent d444648cc0
commit 7da8450075
100 changed files with 518 additions and 143 deletions
@@ -20,7 +20,7 @@ export const FrontComponentRenderer = ({
const theme = useTheme();
const { enqueueErrorSnackBar } = useSnackBar();
const { executionContext, frontComponentHostCommunicationApi } =
useFrontComponentExecutionContext();
useFrontComponentExecutionContext({ frontComponentId });
const handleError = useCallback(
(error?: Error) => {
@@ -0,0 +1,31 @@
import { Suspense, lazy } from 'react';
import { useRecoilValue } from 'recoil';
import { mountedHeadlessFrontComponentIdsState } from '@/front-components/states/mountedHeadlessFrontComponentIdsState';
const FrontComponentRenderer = lazy(() =>
import('@/front-components/components/FrontComponentRenderer').then(
(module) => ({ default: module.FrontComponentRenderer }),
),
);
export const HeadlessFrontComponentMountRoot = () => {
const mountedHeadlessFrontComponentIds = useRecoilValue(
mountedHeadlessFrontComponentIdsState,
);
if (mountedHeadlessFrontComponentIds.size === 0) {
return null;
}
return (
<>
{[...mountedHeadlessFrontComponentIds].map((frontComponentId) => (
<Suspense key={frontComponentId} fallback={null}>
<FrontComponentRenderer frontComponentId={frontComponentId} />
</Suspense>
))}
</>
);
};
@@ -7,6 +7,7 @@ export const FIND_ONE_FRONT_COMPONENT = gql`
name
applicationId
builtComponentChecksum
isHeadless
applicationTokenPair {
applicationAccessToken {
token
@@ -1,4 +1,4 @@
import { useRecoilValue } from 'recoil';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import {
type FrontComponentExecutionContext,
type FrontComponentHostCommunicationApi,
@@ -6,14 +6,26 @@ import {
import { type AppPath } from 'twenty-shared/types';
import { currentUserState } from '@/auth/states/currentUserState';
import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu';
import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState';
import { useUnmountHeadlessFrontComponent } from '@/front-components/hooks/useUnmountHeadlessFrontComponent';
import { useIcons } from 'twenty-ui/display';
import { useNavigateApp } from '~/hooks/useNavigateApp';
export const useFrontComponentExecutionContext = (): {
export const useFrontComponentExecutionContext = ({
frontComponentId,
}: {
frontComponentId: string;
}): {
executionContext: FrontComponentExecutionContext;
frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi;
} => {
const currentUser = useRecoilValue(currentUserState);
const navigateApp = useNavigateApp();
const { navigateCommandMenu } = useNavigateCommandMenu();
const setCommandMenuSearchState = useSetRecoilState(commandMenuSearchState);
const { getIcon } = useIcons();
const unmountHeadlessFrontComponent = useUnmountHeadlessFrontComponent();
const navigate: FrontComponentHostCommunicationApi['navigate'] = async (
to,
@@ -29,13 +41,33 @@ export const useFrontComponentExecutionContext = (): {
);
};
const openSidePanelPage: FrontComponentHostCommunicationApi['openSidePanelPage'] =
async ({ page, pageTitle, pageIcon, shouldResetSearchState }) => {
navigateCommandMenu({
page,
pageTitle,
pageIcon: getIcon(pageIcon),
});
if (shouldResetSearchState === true) {
setCommandMenuSearchState('');
}
};
const executionContext: FrontComponentExecutionContext = {
userId: currentUser?.id ?? null,
};
const unmountFrontComponent: FrontComponentHostCommunicationApi['unmountFrontComponent'] =
async () => {
unmountHeadlessFrontComponent(frontComponentId);
};
const frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi =
{
navigate,
openSidePanelPage,
unmountFrontComponent,
};
return {
@@ -0,0 +1,17 @@
import { useRecoilCallback } from 'recoil';
import { mountedHeadlessFrontComponentIdsState } from '@/front-components/states/mountedHeadlessFrontComponentIdsState';
export const useMountHeadlessFrontComponent = () => {
const mountHeadlessFrontComponent = useRecoilCallback(
({ set }) =>
(frontComponentId: string) => {
set(mountedHeadlessFrontComponentIdsState, (previousIds) =>
new Set(previousIds).add(frontComponentId),
);
},
[],
);
return mountHeadlessFrontComponent;
};
@@ -0,0 +1,19 @@
import { useRecoilCallback } from 'recoil';
import { mountedHeadlessFrontComponentIdsState } from '@/front-components/states/mountedHeadlessFrontComponentIdsState';
export const useUnmountHeadlessFrontComponent = () => {
const unmountHeadlessFrontComponent = useRecoilCallback(
({ set }) =>
(frontComponentId: string) => {
set(mountedHeadlessFrontComponentIdsState, (previousIds) => {
const next = new Set(previousIds);
next.delete(frontComponentId);
return next;
});
},
[],
);
return unmountHeadlessFrontComponent;
};
@@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const mountedHeadlessFrontComponentIdsState = atom<Set<string>>({
key: 'mountedHeadlessFrontComponentIdsState',
default: new Set(),
});