import { ErrorBoundary } from '@servicetitan/error-boundary'; import { type LDService } from '@servicetitan/launchdarkly-service'; import { Log } from '@servicetitan/log-service'; import { useOptionalDependencies } from '@servicetitan/react-ioc'; import { ExposedDependencies } from '@servicetitan/startup-utils'; import { ReactElement, ReactNode, Ref, Suspense, forwardRef, lazy, useEffect, useImperativeHandle, useMemo, useRef, } from 'react'; import { EXPOSED_DEPENDENCIES_TOKEN, EXPOSED_INSTANCE_DEPENDENCIES_TOKEN, ExposedInstanceDependencies, GetJsonError, } from './common'; import { MFEContextData } from './contexts/mfe-data-context'; import { Delayed } from './delayed'; import { EVENT_BUS_TOKEN, EventBus } from './event-bus'; import { EXPOSED_DEPENDENCIES, EXPOSED_INSTANCE_DEPENDENCIES } from './globals'; import { BundleInfo, getBundleInfo, getLDServiceToken, loadScripts, validateBundleInfo, webComponent, } from './utils'; export type LoaderRef = EventBus; export type FallbackNode = NonNullable | null; export interface UnrestrictedLoaderProps { src: string; fallbackSrc?: string; /** Values in the object will be stringified and then parsed before being delivered to the MFE via props or context. */ data?: MFEContextData; basename?: string; loadingFallback?: FallbackNode; loadingFallbackDelayed?: boolean; errorFallback?: FallbackNode; className?: string; retries?: number; isHeadless?: boolean; cache?: boolean | number; onRequestError?(error: GetJsonError): boolean; } export type LoaderProps = Pick< UnrestrictedLoaderProps, | 'src' | 'fallbackSrc' | 'data' | 'basename' | 'loadingFallback' | 'loadingFallbackDelayed' | 'errorFallback' | 'className' | 'retries' | 'cache' | 'onRequestError' >; export type LoaderDataProps = Pick & { LoadingFallback: () => JSX.Element; }; const UnrestrictedLoader = (props: UnrestrictedLoaderProps, ref: Ref) => { const { cache = -1, basename, fallbackSrc, isHeadless, onRequestError, retries, src } = props; const { loadingFallback, loadingFallbackDelayed = true } = props; const bus = useEventBus(); const [logService, exposedDependencies = EXPOSED_DEPENDENCIES, eventBus = bus] = useOptionalDependencies(Log, EXPOSED_DEPENDENCIES_TOKEN, EVENT_BUS_TOKEN); let [exposedInstanceDependencies = EXPOSED_INSTANCE_DEPENDENCIES] = useOptionalDependencies( EXPOSED_INSTANCE_DEPENDENCIES_TOKEN ); const [ldService] = useOptionalDependencies(getLDServiceToken() ?? Symbol()); const LoadingFallback = useMemo(() => { const message = `Loading${isLocalHost(src) ? ` ${src}` : ''}...`; return getFallback(loadingFallback, message, { delayed: loadingFallbackDelayed }); }, [loadingFallback, loadingFallbackDelayed, src]); const ErrorFallback = useMemo(() => { const message = `Something went wrong${isLocalHost(src) ? ` loading ${src}` : ''}`; return getFallback(props.errorFallback, message); }, [props.errorFallback, src]); exposedInstanceDependencies = useMemo( () => ensureLDService(exposedInstanceDependencies, ldService), [exposedInstanceDependencies, ldService] ); const Loader = useMemo(() => { const cancelation = new AbortController(); return lazyLoader({ basename, cache, cancelation, exposedDependencies, exposedInstanceDependencies, eventBus, fallbackSrc, isHeadless, logService, onRequestError, retries, src, }); }, [ basename, cache, exposedDependencies, exposedInstanceDependencies, eventBus, fallbackSrc, isHeadless, logService, onRequestError, retries, src, ]); useImperativeHandle(ref, () => eventBus, [eventBus]); return ( }> ); }; export const Loader = forwardRef(UnrestrictedLoader) as ( props: LoaderProps> & { ref?: Ref } ) => ReturnType; type LazyLoaderProps = UnrestrictedLoaderProps & { cancelation: AbortController; exposedDependencies?: ExposedDependencies; exposedInstanceDependencies?: ExposedInstanceDependencies; eventBus: EventBus; logService?: Log; cache: Required>['cache']; }; function lazyLoader(props: LazyLoaderProps) { const { cancelation } = props; const LazyWebComponent = lazyWebComponent(props); return (props: LoaderDataProps) => { useEffect(() => { return () => { cancelation.abort(); }; }, []); return ( ); }; } function lazyWebComponent(props: LazyLoaderProps) { const { basename, cache, cancelation, exposedDependencies, exposedInstanceDependencies, eventBus, fallbackSrc, isHeadless, logService, onRequestError, retries, src, } = props; return lazy<(_: LoaderDataProps) => JSX.Element | null>(async () => { let bundleInfo: BundleInfo; try { bundleInfo = await getBundleInfo({ cache, mainPackageUrl: src, fallbackPackageUrl: fallbackSrc, isHeadless, exposedDependencies, exposedInstanceDependencies, signal: cancelation.signal, retries, onRequestError, }); } catch (e) { if (cancelation.signal.aborted) { return { default: () => null, }; } throw e; } validateBundleInfo(bundleInfo, { exposedDependencies, exposedInstanceDependencies, logService, packageUrl: src, }); await loadScripts(bundleInfo); const hostLDService = exposedInstanceDependencies?.launchDarkly?.ldService; const hostSupportsVersionedLDClient = typeof hostLDService?.getVersionClient === 'function'; const ldService = bundleInfo.mfeHasCompatibleLaunchDarklyVersion || (bundleInfo.mfeSupportsVersionedLDClient && hostSupportsVersionedLDClient) ? hostLDService : undefined; return { default: webComponent({ basename, bundleInfo, eventBus, exposedDependencies, exposedInstanceDependencies, ldService, logService, }), }; }); } function getFallback( node: FallbackNode | undefined, message: string, { delayed = false }: { delayed?: boolean } = {} ) { const fallbackNode = node !== undefined ? (node as ReactElement) :

{message}

; return delayed ? () => {fallbackNode} : () => fallbackNode; } function isLocalHost(source: string) { return ['localhost', '127.0.0.1'].some(host => source.indexOf(host) >= 0); } function ensureLDService( exposedInstanceDependencies?: ExposedInstanceDependencies, ldService?: LDService ) { const launchDarkly = exposedInstanceDependencies?.launchDarkly; if (launchDarkly?.version && !launchDarkly.ldService && ldService) { return { ...exposedInstanceDependencies, launchDarkly: { ...launchDarkly, ldService } }; } return exposedInstanceDependencies; } function useEventBus() { return useRef(new EventBus()).current; }