[FRONT COMPONENTS] Navigate from the remote (#17762)

## PR Description

This PR:
- Introduces a `FrontComponentHostCommunicationApi`, which allows us to
pass functions to be executed from the worker
- Exposes a navigate function from the host to the front component
remote workers, enabling SDK components to trigger in-app navigation
- Gets rid of `useSyncExternalStore` and makes the execution context
reactive without it
- Refactors and improves the `esbuild` plugin system

## Video


https://github.com/user-attachments/assets/7b26a1c2-f85f-4898-a71d-f60c70e61711
This commit is contained in:
Raphaël Bosi
2026-02-09 14:38:35 +01:00
committed by GitHub
parent 2106f46e9e
commit 2b29918bf8
39 changed files with 689 additions and 293 deletions
@@ -1,6 +1,8 @@
import { FrontComponentErrorEffect } from '@/front-component/remote/components/FrontComponentErrorEffect';
import { FrontComponentHostCommunicationApiEffect } from '@/front-component/remote/components/FrontComponentHostCommunicationApiEffect';
import { FrontComponentUpdateContextEffect } from '@/front-component/remote/components/FrontComponentUpdateContextEffect';
import { type FrontComponentExecutionContext } from '@/front-component/types/FrontComponentExecutionContext';
import { type FrontComponentHostCommunicationApi } from '@/front-component/types/FrontComponentHostCommunicationApi';
import { type WorkerExports } from '@/front-component/types/WorkerExports';
import { type ThreadWebWorker } from '@quilted/threads';
import {
@@ -18,6 +20,7 @@ import { componentRegistry } from '../generated/host-component-registry';
type FrontComponentContentProps = {
componentUrl: string;
executionContext: FrontComponentExecutionContext;
frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi;
onError: (error?: Error) => void;
theme: ThemeType;
};
@@ -25,25 +28,34 @@ type FrontComponentContentProps = {
export const FrontComponentRenderer = ({
componentUrl,
executionContext,
frontComponentHostCommunicationApi,
onError,
theme,
}: FrontComponentContentProps) => {
const [receiver, setReceiver] = useState<RemoteReceiver | null>(null);
const [thread, setThread] = useState<ThreadWebWorker<WorkerExports> | null>(
null,
);
const [thread, setThread] = useState<ThreadWebWorker<
WorkerExports,
FrontComponentHostCommunicationApi
> | null>(null);
const [error, setError] = useState<Error | null>(null);
const MemoizedFrontComponentWorkerEffect = useMemo(() => {
return (
<FrontComponentWorkerEffect
componentUrl={componentUrl}
frontComponentHostCommunicationApi={frontComponentHostCommunicationApi}
setReceiver={setReceiver}
setThread={setThread}
setError={setError}
/>
);
}, [componentUrl, setError, setReceiver, setThread]);
}, [
componentUrl,
frontComponentHostCommunicationApi,
setError,
setReceiver,
setThread,
]);
return (
<>
@@ -54,10 +66,13 @@ export const FrontComponentRenderer = ({
)}
{isDefined(thread) && (
<FrontComponentUpdateContextEffect
thread={thread}
executionContext={executionContext}
/>
<>
<FrontComponentHostCommunicationApiEffect thread={thread} />
<FrontComponentUpdateContextEffect
thread={thread}
executionContext={executionContext}
/>
</>
)}
{isDefined(receiver) && (
@@ -1,12 +1,9 @@
export { FrontComponentRenderer } from './host/components/FrontComponentRenderer';
export { componentRegistry } from './host/generated/host-component-registry';
export { FrontComponentErrorEffect } from './remote/components/FrontComponentErrorEffect';
export { FrontComponentHostCommunicationApiEffect } from './remote/components/FrontComponentHostCommunicationApiEffect';
export { FrontComponentUpdateContextEffect } from './remote/components/FrontComponentUpdateContextEffect';
export { FrontComponentWorkerEffect } from './remote/components/FrontComponentWorkerEffect';
export {
FrontComponentExecutionContextStore,
frontComponentExecutionContextStore,
} from './remote/context/FrontComponentExecutionContextStore';
export {
HtmlA,
HtmlArticle,
@@ -93,8 +90,8 @@ export {
HtmlTdElement,
HtmlTextareaElement,
HtmlTfootElement,
HtmlThElement,
HtmlTheadElement,
HtmlThElement,
HtmlTrElement,
HtmlUlElement,
RemoteFragmentElement,
@@ -119,6 +116,7 @@ export type {
} from './remote/generated/remote-elements';
export { createRemoteWorker } from './remote/worker/createRemoteWorker';
export type { FrontComponentExecutionContext } from './types/FrontComponentExecutionContext';
export type { FrontComponentHostCommunicationApi } from './types/FrontComponentHostCommunicationApi';
export type { HostToWorkerRenderContext } from './types/HostToWorkerRenderContext';
export type { PropertySchema } from './types/PropertySchema';
export type { WorkerExports } from './types/WorkerExports';
@@ -0,0 +1,20 @@
import { type FrontComponentHostCommunicationApi } from '@/front-component/types/FrontComponentHostCommunicationApi';
import { type WorkerExports } from '@/front-component/types/WorkerExports';
import { type ThreadWebWorker } from '@quilted/threads';
import { useEffect } from 'react';
type FrontComponentHostCommunicationApiEffectProps = {
thread: ThreadWebWorker<WorkerExports, FrontComponentHostCommunicationApi>;
};
export const FrontComponentHostCommunicationApiEffect = ({
thread,
}: FrontComponentHostCommunicationApiEffectProps) => {
useEffect(() => {
thread.imports.initializeHostCommunicationApi().catch((error) => {
console.error('Failed to initialize host communication API:', error);
});
}, [thread]);
return null;
};
@@ -1,10 +1,11 @@
import { type FrontComponentExecutionContext } from '@/front-component/types/FrontComponentExecutionContext';
import { type FrontComponentHostCommunicationApi } from '@/front-component/types/FrontComponentHostCommunicationApi';
import { type WorkerExports } from '@/front-component/types/WorkerExports';
import { type ThreadWebWorker } from '@quilted/threads';
import { useEffect } from 'react';
type FrontComponentUpdateContextEffectProps = {
thread: ThreadWebWorker<WorkerExports>;
thread: ThreadWebWorker<WorkerExports, FrontComponentHostCommunicationApi>;
executionContext: FrontComponentExecutionContext;
};
@@ -1,24 +1,36 @@
import { ThreadWebWorker, release, retain } from '@quilted/threads';
import { RemoteReceiver } from '@remote-dom/core/receivers';
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { type FrontComponentHostCommunicationApi } from '../../types/FrontComponentHostCommunicationApi';
import { type WorkerExports } from '../../types/WorkerExports';
import { createRemoteWorker } from '../worker/createRemoteWorker';
type FrontComponentWorkerEffectProps = {
componentUrl: string;
frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi;
setReceiver: React.Dispatch<React.SetStateAction<RemoteReceiver | null>>;
setThread: React.Dispatch<
React.SetStateAction<ThreadWebWorker<WorkerExports> | null>
React.SetStateAction<ThreadWebWorker<
WorkerExports,
FrontComponentHostCommunicationApi
> | null>
>;
setError: React.Dispatch<React.SetStateAction<Error | null>>;
};
export const FrontComponentWorkerEffect = ({
componentUrl,
frontComponentHostCommunicationApi,
setReceiver,
setThread,
setError,
}: FrontComponentWorkerEffectProps) => {
const frontComponentHostCommunicationApiRef = useRef(
frontComponentHostCommunicationApi,
);
frontComponentHostCommunicationApiRef.current =
frontComponentHostCommunicationApi;
useEffect(() => {
const newReceiver = new RemoteReceiver({ retain, release });
@@ -28,7 +40,19 @@ export const FrontComponentWorkerEffect = ({
setError(event.error);
};
const thread = new ThreadWebWorker<WorkerExports>(worker);
// Expose host functions to the worker via stable refs to avoid recreating threads
const stableFrontComponentHostCommunicationApi: FrontComponentHostCommunicationApi =
{
navigate: (...args) =>
frontComponentHostCommunicationApiRef.current.navigate(...args),
};
const thread = new ThreadWebWorker<
WorkerExports,
FrontComponentHostCommunicationApi
>(worker, {
exports: stableFrontComponentHostCommunicationApi,
});
setThread(thread);
thread.imports
@@ -1,28 +0,0 @@
import { type FrontComponentExecutionContext } from '../../types/FrontComponentExecutionContext';
type Listener = () => void;
export class FrontComponentExecutionContextStore {
private context: FrontComponentExecutionContext | undefined = undefined;
private listeners = new Set<Listener>();
getSnapshot = (): FrontComponentExecutionContext | undefined => {
return this.context;
};
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
setContext = (context: FrontComponentExecutionContext): void => {
this.context = context;
for (const listener of this.listeners) {
listener();
}
};
}
export const frontComponentExecutionContextStore =
new FrontComponentExecutionContextStore();
@@ -0,0 +1,5 @@
export const exposeGlobals = (globals: Record<string, unknown>): void => {
for (const [key, value] of Object.entries(globals)) {
(globalThis as Record<string, unknown>)[key] = value;
}
};
@@ -12,18 +12,31 @@ import {
import React from 'react';
import { createRoot } from 'react-dom/client';
import { jsx, jsxs } from 'react/jsx-runtime';
import * as TwentySharedTypes from 'twenty-shared/types';
import * as TwentySharedUtils from 'twenty-shared/utils';
import { setFrontComponentExecutionContext } from '@/sdk/front-component-api/context/frontComponentContext';
import { setNavigate } from '@/sdk/front-component-api/functions/navigate';
import * as TwentySdk from '@/sdk';
import { type FrontComponentExecutionContext } from '../../types/FrontComponentExecutionContext';
import { type FrontComponentHostCommunicationApi } from '../../types/FrontComponentHostCommunicationApi';
import { type HostToWorkerRenderContext } from '../../types/HostToWorkerRenderContext';
import { type WorkerExports } from '../../types/WorkerExports';
import { frontComponentExecutionContextStore } from '../context/FrontComponentExecutionContextStore';
import * as RemoteComponents from '../generated/remote-components';
import { exposeGlobals } from '../utils/exposeGlobals';
(globalThis as Record<string, unknown>).React = React;
(globalThis as Record<string, unknown>).RemoteComponents = RemoteComponents;
(globalThis as Record<string, unknown>).jsx = jsx;
(globalThis as Record<string, unknown>).jsxs = jsxs;
(globalThis as Record<string, unknown>).frontComponentExecutionContextStore =
frontComponentExecutionContextStore;
exposeGlobals({
React,
RemoteComponents,
jsx,
jsxs,
TwentySdk,
TwentyShared: {
utils: TwentySharedUtils,
types: TwentySharedTypes,
},
});
const render: WorkerExports['render'] = async (
connection: RemoteConnection,
@@ -41,10 +54,21 @@ const render: WorkerExports['render'] = async (
reactRoot.render(componentModule.default);
};
const initializeHostCommunicationApi: WorkerExports['initializeHostCommunicationApi'] =
async () => {
const hostApi =
ThreadWebWorker.self.import<FrontComponentHostCommunicationApi>();
setNavigate(hostApi.navigate);
};
const updateContext: WorkerExports['updateContext'] = async (
context: FrontComponentExecutionContext,
) => {
frontComponentExecutionContextStore.setContext(context);
setFrontComponentExecutionContext(context);
};
ThreadWebWorker.self.export({ render, updateContext });
ThreadWebWorker.self.export({
render,
initializeHostCommunicationApi,
updateContext,
});
@@ -1,3 +1,4 @@
// Serializable execution context that can be passed via postMessage (no functions)
export type FrontComponentExecutionContext = {
userId: string | null;
};
@@ -0,0 +1,10 @@
import { type AppPath, type NavigateOptions } from 'twenty-shared/types';
export type FrontComponentHostCommunicationApi = {
navigate: (
to: AppPath,
params?: Record<string, string | null>,
queryParams?: Record<string, unknown>,
options?: NavigateOptions,
) => Promise<void>;
};
@@ -7,5 +7,6 @@ export type WorkerExports = {
connection: RemoteConnection,
context: HostToWorkerRenderContext,
) => Promise<void>;
initializeHostCommunicationApi: () => Promise<void>;
updateContext: (context: FrontComponentExecutionContext) => Promise<void>;
};