import { type LDService } from '@servicetitan/launchdarkly-service'; import { Log } from '@servicetitan/log-service'; import { Provider } from '@servicetitan/react-ioc'; import { ExposedDependencies } from '@servicetitan/startup-utils'; import { ComponentType, FC, ReactElement, useEffect } from 'react'; import { Anvil1Providers } from './anvil1-providers'; import { Anvil2Providers } from './anvil2-providers'; import { BASENAME_TOKEN, Entries, EXPOSED_DEPENDENCIES_TOKEN, EXPOSED_INSTANCE_DEPENDENCIES_TOKEN, ExposedInstanceDependencies, IS_WEB_COMPONENT_TOKEN, IWebComponent, RENDER_ROOT_TOKEN, } from './common'; import { MFEDataContext } from './contexts/mfe-data-context'; import { MFEMetadataContext } from './contexts/mfe-metadata-context'; import { withCssInjector } from './css-injector'; import { EVENT_BUS_TOKEN, EventBus } from './event-bus'; import { getStyleUrls } from './get-style-urls'; import { WEB_COMPONENT_NAME } from './globals'; import { HistoryManager } from './history-manager'; import { render } from './render'; import { getElementName, getLDServiceToken } from './utils'; function createLink(url: string, onLoad: () => void) { const element = document.createElement('link'); element.href = url; element.rel = 'stylesheet'; element.crossOrigin = 'anonymous'; element.onload = onLoad; element.onerror = onLoad; return element; } const Placeholder: FC = () =>
; export interface RegisterOptions { legacyRoot?: boolean; sharedDependenciesNames?: string[]; } export function register(Component: ComponentType, light: boolean, options?: RegisterOptions) { const WrappedComponent = withCssInjector(Component); class WebComponent extends HTMLElement implements IWebComponent { private root?: ShadowRoot; private portal?: ShadowRoot; private portalAnvilContainer?: HTMLDivElement; private styles: { total: number; loaded: number; elements: HTMLElement[]; } = { total: 0, loaded: 0, elements: [] }; private ldService?: LDService; private logService?: Log; private historyManager?: HistoryManager; private exposedDependencies?: ExposedDependencies; private exposedInstanceDependencies?: ExposedInstanceDependencies; private eventBus?: EventBus; private basename?: string; private onReady?: () => void; private onDispose?: () => void; private view?: ReturnType; connectedCallback() { /* * connectedCallback can be called when DOM element has moved * check that root is still connected to the DOM * if that's the case we don't need to reconnect MFE */ if (this.root?.isConnected) { return; } const sharedDependenciesNames = options?.sharedDependenciesNames ?? []; const styleUrls = getStyleUrls(light, sharedDependenciesNames, this); this.styles = { total: styleUrls.length * 2, loaded: 0, elements: [], }; this.root = this.shadowRoot ?? this.attachShadow({ mode: 'open' }); /* * Portal shadow dom is created at the bottom of the body in order to be used as * the element for the design system components to attach modals and popups to. * This element is passed down the tree through context, and used within * the design system components. */ const portalElement = document.createElement('div'); portalElement.setAttribute( 'data-mfe-portal-name', getElementName({ WebComponent: WEB_COMPONENT_NAME! }) ); this.portal = document.body.appendChild(portalElement).attachShadow({ mode: 'open' }); /* * Add element inside of portal so that Anvil 2 can attach modals and popups to it * since floating-ui does not support attaching straight to shadow dom */ this.portalAnvilContainer = document.createElement('div'); this.portal.appendChild(this.portalAnvilContainer); this.view = render(, this.root, options); this.styles.elements.push( ...this.attachStyles(this.root, styleUrls), ...this.attachStyles(this.portal, styleUrls) ); } disconnectedCallback() { if (!this.root || this.root.isConnected) { return; } for (const element of this.styles.elements) { element.remove(); } this.view?.unmount(); document.body?.removeChild(this.portal!.host); this.root = undefined; this.portal = undefined; this.portalAnvilContainer = undefined; this.onDispose?.(); } // Observe changes to the data-mfe-data attribute so that we can re-render when it changes static get observedAttributes() { return ['data-mfe-data']; } attributeChangedCallback() { this.render(); } provide = (entries: Entries) => { for (const [key, value] of Object.entries(entries)) { this[key as keyof IWebComponent] = value; } if (this.styles.loaded === this.styles.total) { this.render(); } }; private handleLoad = () => { this.styles.loaded++; if (this.styles.loaded === this.styles.total) { this.render(); } }; private attachStyles = (node: ShadowRoot, urls: string[]) => { const elements: HTMLElement[] = []; for (const url of urls) { const element = createLink(url, this.handleLoad); node.appendChild(element); elements.push(element); } return elements; }; private withProviders(children: ReactElement) { return ( {children} ); } private render = () => { /* * Check for this.root first, otherwise we get error when * attributeChangedCallback happens on load */ if (this.root) { const mfeData = this.dataset.mfeData ? /* * Data was stringified in order to come through the data-mfe-data attribute * So we must JSON.parse it here to get an object back */ JSON.parse(this.dataset.mfeData) : /* * If mfeData isn't there, then the Host may not be up to date with web-components * and is instead passing data through separate data-attributes, so we want to * spread all of those instead */ { ...this.dataset }; const metadata = { shadowRoot: this.root, portalShadowRoot: this.portal!, }; this.view?.rerender( useValue !== undefined && provide !== undefined )} > {this.withProviders()} {this.onReady && } ); } }; } if (!WEB_COMPONENT_NAME) { // eslint-disable-next-line no-console console.error('"WEB_COMPONENT_NAME" is not defined!'); return; } window.customElements.define(WEB_COMPONENT_NAME, WebComponent); } function RenderCallback({ callback }: { callback: () => void }) { // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => callback(), []); return null; }