37908114fc
Followup https://github.com/twentyhq/twenty/pull/19010 ## Dependency diagram ``` ┌─────────────────────┐ │ twenty-front │ │ (React frontend) │ └─────────┬───────────┘ │ imports runtime: │ FrontComponentRenderer │ FrontComponentRendererWithSdkClient │ useFrontComponentExecutionContext ▼ ┌──────────────────────────────────┐ ┌─────────────────────────┐ │ twenty-front-component-renderer │────────▶│ twenty-sdk │ │ (remote-dom host + worker) │ │ (app developer SDK) │ │ │ │ │ │ imports from twenty-sdk: │ │ Public API: │ │ • types only: │ │ defineFrontComponent │ │ FrontComponentExecutionContext│ │ navigate, closeSide… │ │ NavigateFunction │ │ useFrontComponent… │ │ CloseSidePanelFunction │ │ Command components │ │ CommandConfirmation… │ │ conditional avail. │ │ OpenCommandConfirmation… │ │ │ │ EnqueueSnackbarFunction │ │ Internal only: │ │ etc. │ │ frontComponentHost… │ │ │ │ front-component-build │ │ owns locally: │ │ esbuild plugins │ │ • ALLOWED_HTML_ELEMENTS │ │ │ │ • EVENT_TO_REACT │ └────────────┬────────────┘ │ • HTML_TAG_TO_CUSTOM_ELEMENT… │ │ │ • SerializedEventData │ │ types │ • PropertySchema │ ▼ │ • frontComponentHostComm… │ ┌─────────────────────────┐ │ (local ref to globalThis) │ │ twenty-shared │ │ • setFrontComponentExecution… │ │ (common types/utils) │ │ (local impl, same keys) │ │ AppPath, SidePanelP… │ │ │ │ EnqueueSnackbarParams │ └──────────────────────────────────┘ │ isDefined, … │ │ └─────────────────────────┘ │ also depends on ▼ twenty-shared (types) @remote-dom/* (runtime) @quilted/threads (runtime) react (runtime) ``` **Key points:** - **`twenty-front`** depends on the renderer, **not** on `twenty-sdk` directly (for rendering) - **`twenty-front-component-renderer`** depends on `twenty-sdk` for **types only** (function signatures, `FrontComponentExecutionContext`). The runtime bridge (`frontComponentHostCommunicationApi`) is shared via `globalThis` keys, not module imports - **`twenty-sdk`** has no dependency on the renderer — clean one-way dependency - The renderer owns all remote-dom infrastructure (element schemas, event mappings, custom element tags) that was previously leaking through the SDK's public API - The SDK's `./build` entry point was removed entirely (unused)
253 lines
7.2 KiB
TypeScript
253 lines
7.2 KiB
TypeScript
import { type RemoteRootElement } from '@remote-dom/core/elements';
|
|
|
|
import { type RemoteStyleProperties } from '@/remote/generated/remote-elements';
|
|
import { MockCSSStyleSheet } from './MockCSSStyleSheet';
|
|
|
|
export const installStyleBridge = (remoteRoot: RemoteRootElement): void => {
|
|
const styleElementMap = new WeakMap<
|
|
Element,
|
|
Element & RemoteStyleProperties
|
|
>();
|
|
const styleObserverMap = new WeakMap<Element, { disconnect: () => void }>();
|
|
|
|
const trackStyleElement = (styleElement: Element): void => {
|
|
if (styleElementMap.has(styleElement)) {
|
|
return;
|
|
}
|
|
|
|
const remoteStyleElement = document.createElement(
|
|
'remote-style',
|
|
) as Element & RemoteStyleProperties;
|
|
|
|
styleElementMap.set(styleElement, remoteStyleElement);
|
|
|
|
const attributeNames = styleElement.getAttributeNames?.() ?? [];
|
|
const dataAttributes = attributeNames
|
|
.filter((attributeName: string) => attributeName.startsWith('data-'))
|
|
.map(
|
|
(attributeName: string) =>
|
|
`${attributeName}=${styleElement.getAttribute(attributeName) ?? ''}`,
|
|
)
|
|
.join(';');
|
|
|
|
if (dataAttributes.length > 0) {
|
|
remoteStyleElement.styleKey = dataAttributes;
|
|
}
|
|
|
|
const syncCssFromStyleElement = () => {
|
|
remoteStyleElement.cssText = styleElement.textContent ?? '';
|
|
};
|
|
|
|
const mockSheet = new MockCSSStyleSheet((cssText: string) => {
|
|
remoteStyleElement.cssText = cssText;
|
|
});
|
|
|
|
try {
|
|
Object.defineProperty(styleElement, 'sheet', {
|
|
get: () => mockSheet,
|
|
configurable: true,
|
|
});
|
|
} catch {
|
|
void 0;
|
|
}
|
|
|
|
const prototypeChain = Object.getPrototypeOf(styleElement);
|
|
const textContentDescriptor =
|
|
Object.getOwnPropertyDescriptor(styleElement, 'textContent') ??
|
|
Object.getOwnPropertyDescriptor(prototypeChain, 'textContent') ??
|
|
Object.getOwnPropertyDescriptor(
|
|
Object.getPrototypeOf(prototypeChain),
|
|
'textContent',
|
|
);
|
|
|
|
if (textContentDescriptor?.set) {
|
|
const originalTextContentSet = textContentDescriptor.set;
|
|
try {
|
|
Object.defineProperty(styleElement, 'textContent', {
|
|
get: textContentDescriptor.get,
|
|
set(value: string) {
|
|
originalTextContentSet.call(this, value);
|
|
remoteStyleElement.cssText = value ?? '';
|
|
},
|
|
configurable: true,
|
|
});
|
|
} catch {
|
|
void 0;
|
|
}
|
|
}
|
|
|
|
const originalAppendChild = styleElement.appendChild.bind(styleElement);
|
|
try {
|
|
(
|
|
styleElement as Element & { appendChild: typeof originalAppendChild }
|
|
).appendChild = (child: Node) => {
|
|
const result = originalAppendChild(child);
|
|
syncCssFromStyleElement();
|
|
return result;
|
|
};
|
|
} catch {
|
|
void 0;
|
|
}
|
|
|
|
const originalInsertBeforeOnStyle =
|
|
styleElement.insertBefore.bind(styleElement);
|
|
try {
|
|
(
|
|
styleElement as Element & {
|
|
insertBefore: typeof originalInsertBeforeOnStyle;
|
|
}
|
|
).insertBefore = <T extends Node>(child: T, ref: Node | null): T => {
|
|
const result = originalInsertBeforeOnStyle(child, ref);
|
|
syncCssFromStyleElement();
|
|
return result;
|
|
};
|
|
} catch {
|
|
void 0;
|
|
}
|
|
|
|
const originalRemoveChildOnStyle =
|
|
styleElement.removeChild.bind(styleElement);
|
|
try {
|
|
(
|
|
styleElement as Element & {
|
|
removeChild: typeof originalRemoveChildOnStyle;
|
|
}
|
|
).removeChild = <T extends Node>(child: T): T => {
|
|
const result = originalRemoveChildOnStyle(child);
|
|
syncCssFromStyleElement();
|
|
return result;
|
|
};
|
|
} catch {
|
|
void 0;
|
|
}
|
|
|
|
if (typeof MutationObserver === 'function') {
|
|
try {
|
|
const styleObserver = new MutationObserver(() => {
|
|
syncCssFromStyleElement();
|
|
});
|
|
|
|
if (typeof styleObserver.observe === 'function') {
|
|
styleObserver.observe(styleElement, {
|
|
subtree: true,
|
|
childList: true,
|
|
characterData: true,
|
|
});
|
|
styleObserverMap.set(styleElement, styleObserver);
|
|
}
|
|
} catch {
|
|
void 0;
|
|
}
|
|
}
|
|
|
|
const existingContent = styleElement.textContent;
|
|
if (existingContent) {
|
|
remoteStyleElement.cssText = existingContent;
|
|
}
|
|
|
|
remoteRoot.appendChild(remoteStyleElement);
|
|
};
|
|
|
|
const untrackStyleElement = (styleElement: Element): void => {
|
|
const styleObserver = styleObserverMap.get(styleElement);
|
|
if (styleObserver) {
|
|
styleObserver.disconnect();
|
|
styleObserverMap.delete(styleElement);
|
|
}
|
|
|
|
const remoteStyleElement = styleElementMap.get(styleElement);
|
|
|
|
if (remoteStyleElement && remoteStyleElement.parentNode) {
|
|
remoteStyleElement.parentNode.removeChild(remoteStyleElement);
|
|
styleElementMap.delete(styleElement);
|
|
}
|
|
};
|
|
|
|
const headElement = document.head;
|
|
const originalAppendChild = headElement.appendChild.bind(headElement);
|
|
const originalInsertBefore = headElement.insertBefore.bind(headElement);
|
|
const originalAppend = headElement.append.bind(headElement);
|
|
const originalPrepend = headElement.prepend.bind(headElement);
|
|
const originalReplaceChild = headElement.replaceChild.bind(headElement);
|
|
const originalRemoveChild = headElement.removeChild.bind(headElement);
|
|
|
|
headElement.appendChild = <T extends Node>(child: T): T => {
|
|
const result = originalAppendChild(child);
|
|
if (isStyleElement(child)) {
|
|
trackStyleElement(child as unknown as Element);
|
|
}
|
|
return result;
|
|
};
|
|
|
|
headElement.insertBefore = <T extends Node>(
|
|
child: T,
|
|
ref: Node | null,
|
|
): T => {
|
|
const result = originalInsertBefore(child, ref);
|
|
if (isStyleElement(child)) {
|
|
trackStyleElement(child as unknown as Element);
|
|
}
|
|
return result;
|
|
};
|
|
|
|
headElement.append = (...nodes: (Node | string)[]) => {
|
|
originalAppend(...nodes);
|
|
for (const node of nodes) {
|
|
if (isStyleElement(node)) {
|
|
trackStyleElement(node as Element);
|
|
}
|
|
}
|
|
};
|
|
|
|
headElement.prepend = (...nodes: (Node | string)[]) => {
|
|
originalPrepend(...nodes);
|
|
for (const node of nodes) {
|
|
if (isStyleElement(node)) {
|
|
trackStyleElement(node as Element);
|
|
}
|
|
}
|
|
};
|
|
|
|
headElement.replaceChild = <T extends Node>(
|
|
newChild: Node,
|
|
oldChild: T,
|
|
): T => {
|
|
const result = originalReplaceChild(newChild, oldChild);
|
|
if (isStyleElement(oldChild)) {
|
|
untrackStyleElement(oldChild as unknown as Element);
|
|
}
|
|
if (isStyleElement(newChild)) {
|
|
trackStyleElement(newChild as unknown as Element);
|
|
}
|
|
return result;
|
|
};
|
|
|
|
headElement.removeChild = <T extends Node>(child: T): T => {
|
|
const result = originalRemoveChild(child);
|
|
if (isStyleElement(child)) {
|
|
untrackStyleElement(child as unknown as Element);
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const existingStyleElements = Array.from(
|
|
headElement.querySelectorAll('style'),
|
|
);
|
|
for (const styleElement of existingStyleElements) {
|
|
trackStyleElement(styleElement);
|
|
}
|
|
|
|
Object.defineProperty(document, 'styleSheets', {
|
|
get: () => [],
|
|
configurable: true,
|
|
});
|
|
};
|
|
|
|
const isStyleElement = (node: Node | string): node is Element => {
|
|
return (
|
|
typeof node !== 'string' &&
|
|
'localName' in node &&
|
|
node.localName === 'style'
|
|
);
|
|
};
|