import React, { useContext, useRef, useState } from 'react'; import type { Store as RefluxStore } from 'reflux'; import { Provider as ReduxStoreProvider } from 'react-redux'; import type { Plugin, RefluxActions } from './app-registry'; import { AppRegistry, isReduxStore } from './app-registry'; import { GlobalAppRegistryContext, AppRegistryProvider, useGlobalAppRegistry, useLocalAppRegistry, useIsTopLevelProvider, } from './react-context'; class ActivateHelpersImpl { private cleanupFns = new Set<() => void>(); private deactivateController = new AbortController(); constructor() { this.addCleanup(() => { this.deactivateController.abort(); }); } /** * A signal for the abort controller that will be aborted when the cleanup * method is called. Helpful to be able to abort or check whether or not * plugin is still active in async tasks triggered during plugin activate * phase */ get signal() { return this.deactivateController.signal; } /** * Helper method to subscribe to events on a generic event emitter. Events * will be added to the cleanup set and will be cleaned up when `cleanup` is * called */ on = ( emitter: { on(evt: string, fn: (...args: any) => any): any; removeListener(evt: string, fn: (...args: any) => any): any; }, evt: string, fn: (...args: any) => any ) => { const voidFn = (...args: any) => void fn(...args); emitter.on(evt, voidFn); this.addCleanup(() => { emitter.removeListener(evt, voidFn); }); }; /** * Add an arbitrary callback to the cleanup set */ addCleanup = (fn?: () => void) => { if (fn) { this.cleanupFns.add(fn); } }; /** * Call all the cleanup callbacks. This includes any events listeners set up * with an `on` helper and everything that was added with `addCleanup` */ cleanup = () => { for (const fn of this.cleanupFns.values()) { fn(); } }; } export type ActivateHelpers = Pick< ActivateHelpersImpl, 'on' | 'addCleanup' | 'cleanup' | 'signal' >; export function createActivateHelpers(): ActivateHelpers { return new ActivateHelpersImpl(); } function LegacyRefluxProvider({ store, actions, children, }: { store: Partial; actions?: Partial; children: React.ReactElement; }) { const storeRef = useRef(store); const [state, setState] = useState(() => { return storeRef.current.state; }); React.useEffect(() => { const unsubscribe = storeRef.current.listen?.( (updatedState: Readonly) => setState({ ...updatedState }), null ); return () => unsubscribe?.(); }, []); return React.cloneElement( // There is a ton of issues with cloning children like that, // this is a legacy piece of code that we know works in our cases and so we // can ignore ts errors instead of handling all corner-cases children, // There is no single pattern to how reflux is used by plugins, sometime // store is passed directly, sometimes only state, sometimes actions are // passed as a single prop, sometimes spreaded, sometimes all the approaches // are mixed and used like that in the plugins. Reflux is legacy that we // prefer to not spend time cleaning up so we're just trying to cover all // the cases here as much as possible { store, actions, ...actions, ...state } ); } type Registries = { globalAppRegistry: AppRegistry; localAppRegistry: AppRegistry; }; type Services unknown>> = { [SvcName in keyof S]: ReturnType; }; export type HadronPluginConfig< T, S extends Record unknown>, A extends Plugin > = { name: string; component: React.ComponentType; /** * Plugin activation method, will receive any props passed to the component, * and global and local app registry instances to subscribe to any relevant * events. Should return plugin store and an optional deactivate method to * clean up subscriptions or any other store-related state */ activate: ( initialProps: T, services: Registries & Services, helpers: ActivateHelpers ) => A; }; type MockOptions = { pluginName: string; mockedEnvironment: boolean; mockServices: Record; disableChildPluginRendering: boolean; }; const DEFAULT_MOCK_OPTIONS = { pluginName: '$$root', mockedEnvironment: false, mockServices: {}, disableChildPluginRendering: false, }; const MockOptionsContext = React.createContext(null); const useMockOption = ( key: T, defaultMockOptions = DEFAULT_MOCK_OPTIONS ): MockOptions[T] => { return useContext(MockOptionsContext)?.[key] ?? defaultMockOptions[key]; }; let serviceLocationInProgress = false; const kLocator = Symbol('CompassServiceLocatorFunction'); /** * Helper method to "create" a service locator function for a particular * service. This method guarantees that service locators are used only in * appropriate context. Every service locator function in the application should * be created with this method. */ export function createServiceLocator< T extends (this: any, ...args: any[]) => any >(fn: T, name = fn.name): T { return Object.assign( function (this: any, ...args: any[]) { if (!serviceLocationInProgress) { throw new Error( `Using service locator function "${name}" outside of the service location lifecycle. ` + `Make sure that service locator function is passed as a second argument to the registerHadronPlugin method and is not used directly in a React render method.` ); } return fn.call(this, ...args); }, { [kLocator]: true } ) as unknown as T; } /** * Optional service provider creation method. In some cases service providers * need access to other service locators to facilitate service injections. In * these cases service provider can be wrapped with the createServiceProvider * function to allow usage of serviceLocator functions in providers outside of * the usual hadron plugin "activate" lifecycle. */ export function createServiceProvider>( fn: T ): T { const displayName = `ServiceProvider(${fn.displayName ?? fn.name})`; const Provider = function (props: React.ComponentProps) { let content: React.ReactElement | null; try { serviceLocationInProgress = true; content = fn(props); } finally { serviceLocationInProgress = false; } return <>{content}; }; Provider.displayName = displayName; return Provider as T; } function isServiceLocator(val: any): boolean { return Object.prototype.hasOwnProperty.call(val, kLocator); } function useHadronPluginActivate< T, S extends Record unknown>, A extends Plugin >( config: HadronPluginConfig, services: S | undefined, props: T, mockOptions?: MockOptions ) { const registryName = `${config.name}.Plugin`; const isMockedEnvironment = useMockOption('mockedEnvironment', mockOptions); const mockServices = useMockOption('mockServices', mockOptions); const globalAppRegistry = useGlobalAppRegistry(); const localAppRegistry = useLocalAppRegistry(); let serviceImpls: Services; try { serviceLocationInProgress = true; serviceImpls = Object.fromEntries( Object.keys({ ...(isMockedEnvironment ? mockServices : {}), ...services, }).map((key) => { const usingMockService = isMockedEnvironment && !!mockServices?.[key]; if ( !usingMockService && services?.[key] && !isServiceLocator(services[key]) ) { throw new Error( `Function "${services[key].name}" that is being passed as a service locator can not be identified as one. ` + `Make sure that you created a service locator function using createServiceLocator helper function.` ); } const svc = usingMockService ? mockServices[key] : services?.[key](); try { return [key, svc]; } catch (err) { if ( err && typeof err === 'object' && 'message' in err && typeof err.message === 'string' ) err.message += ` [locating service '${key}' for '${registryName}']`; throw err; } }) ) as Services; } finally { serviceLocationInProgress = false; } const [{ store, actions, context }] = useState( () => localAppRegistry.getPlugin(registryName) ?? (() => { const plugin = config.activate( props, { globalAppRegistry, localAppRegistry, ...serviceImpls, }, createActivateHelpers() ); localAppRegistry.registerPlugin(registryName, plugin); return plugin; })() ); return { store, actions, context }; } export type HadronPluginComponent< T, S extends Record unknown>, A extends Plugin > = React.FunctionComponent & { displayName: string; /** * Hook that will activate plugin in the current rendering scope without * actually rendering it. Useful to set up plugins that are rendered * conditionally and have to subscribe to event listeners earlier than the * first render in their lifecycle * * @example * const Plugin = registerHadronPlugin(...); * * function Component() { * Plugin.useActivate(); * const [pluginVisible] = useState(false); * * // This Plugin component will already have its store set up and listening * // to the events even before rendering * return (pluginVisible && ) * } */ useActivate(props: T): A; /** * Convenience method for testing: allows to override services and app * registries available in the plugin context * * @example * const PluginWithLogger = registerHadronPlugin({ ... }, { logger: loggerLocator }); * * const MockPlugin = PluginWithLogger.withMockServices({ logger: Sinon.stub() }); * * @param mocks Overrides for the services locator values and registries * passed to the plugin in runtime. When `globalAppRegistry` * override is passed, it will be also used for the * localAppRegistry override unless `localAppRegistry` is also * explicitly passed as a mock service. */ withMockServices( mocks: Partial>, options?: Partial> ): HadronPluginComponent; }; /** * Creates a hadron plugin that will be automatically activated on first render * and cleaned up when localAppRegistry unmounts * * @param config Hadron plugin configuration * @param services Map of service locator functions that plugin depends on * * @returns Hadron plugin component * * @example * const CreateCollectionPlugin = registerHadronPlugin({ * name: 'CreateCollection', * component: CreateCollectionModal, * activate(opts, { globalAppRegistry }) { * const store = configureStore(...); * const openCreateCollectionModal = (ns) => { * store.dispatch(openModal(ns)); * } * globalAppRegistry.on('create-collection', openCreateCollectionModal); * return { * store, * deactivate() { * globalAppRegistry.removeEventListener( * 'create-collection', * openCreateCollectionModal * ); * } * } * } * }); * * @example * // app.js * import CompassLogging from '@mongodb-js/compass-logging'; * import { LoggingProvider } from '@mongodb-js/compass-logging/provider'; * * ReactDOM.render( * * * * ) * * // plugin.js * import { logging } from '@mongodb-js/compass-logging/provider' * * const PluginWithLogger = registerHadronPlugin({ * name: 'LoggingPlugin', * component: () => null, * activate(opts, { logging }) { * logging.log('Plugin activated!'); * } * }, { logging }) */ export function registerHadronPlugin< T, S extends Record unknown>, A extends Plugin >( config: HadronPluginConfig, services?: S ): HadronPluginComponent { const Component = config.component; const Plugin = (props: React.PropsWithChildren) => { const isMockedEnvironment = useMockOption('mockedEnvironment'); const mockedPluginName = useMockOption('pluginName'); const disableRendering = useMockOption('disableChildPluginRendering'); // This can only be true in test environment when parent plugin is setup // with mock services. We allow parent to render, but any other plugin // that would try to render afterwards will just return `null` avoiding // the need for providing services for child plugins if ( isMockedEnvironment && mockedPluginName !== config.name && disableRendering ) { return null; } // We don't actually use this hook conditionally, even though eslint rule // thinks so: values returned by `useMock*` hooks are constant in React // runtime // eslint-disable-next-line react-hooks/rules-of-hooks const { store, actions, context } = useHadronPluginActivate( config, services, props ); if (isReduxStore(store)) { return ( ); } return ( ); }; return Object.assign(Plugin, { displayName: config.name, useActivate: (props: T): A => { return useHadronPluginActivate(config, services, props) as A; }, withMockServices( mocks: Partial> = {}, options?: Partial> ): HadronPluginComponent { const { // In case globalAppRegistry mock is not provided, we use the one // created in scope so that plugins don't leak their events and // registered metadata on the globalAppRegistry globalAppRegistry = new AppRegistry(), // If localAppRegistry is not explicitly provided, use the passed mock // for global app registry instead localAppRegistry = globalAppRegistry, ...mockServices } = mocks; // These services will be passed to the plugin `activate` method second // argument const mockOptions = { ...DEFAULT_MOCK_OPTIONS, mockedEnvironment: true, pluginName: config.name, mockServices, ...options, }; function MockPluginWithContext(props: T) { const isTopLevelProvider = useIsTopLevelProvider(); const hasCustomAppRegistries = !!mockServices.localAppRegistry || !!mockServices.globalAppRegistry; // Only render these providers if there are no providers for app // registry set up higher in the rendering tree or if user explicitly // passed some mocks here. In other cases we're probably be interferring // with some custom providers setup by the test code const shouldRenderRegistryProviders = isTopLevelProvider || hasCustomAppRegistries; return ( {shouldRenderRegistryProviders ? ( ) : ( )} ); } return Object.assign(MockPluginWithContext, { displayName: config.name, useActivate: (props: T): A => { return useHadronPluginActivate( config, services, props, mockOptions ) as A; }, withMockServices() { return MockPluginWithContext as any; }, }); }, }); }