[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:
@@ -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';
|
||||
|
||||
+20
@@ -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;
|
||||
};
|
||||
+2
-1
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
+27
-3
@@ -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
|
||||
|
||||
-28
@@ -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>;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user