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;
}