remix-nlux
Version:
Remix IDE NLUX integration. Remix IDE is the leading IDE for building and deploying smart contracts on Ethereum. NLUX is a JavaScript and React library for building conversational AI experiences.
583 lines (487 loc) • 20.9 kB
text/typescript
import {CallbackArgType, CallbackFunction} from '@shared/types/callbackFunction';
import {NluxError, NluxUsageError} from '@shared/types/error';
import {domOp} from '@shared/utils/dom/domOp';
import {emptyInnerHtml} from '@shared/utils/dom/emptyInnerHtml';
import {uid} from '@shared/utils/uid';
import {warn} from '@shared/utils/warn';
import {CompDef, CompDom, CompRenderer, CompUpdater} from '../../types/comp';
import {ControllerContext} from '../../types/controllerContext';
import {CompRegistry} from './registry';
export type CompStatus = 'unmounted' | 'rendered' | 'active' | 'destroyed';
export abstract class BaseComp<AiMsg, PropsType, ElementsType, EventsType, ActionsType> {
static __compEventListeners: Map<string | number | symbol, string[]> | null = null;
static __compId: string | null = null;
static __renderer: CompRenderer<unknown, unknown, unknown, unknown> | null = null;
static __updater: CompUpdater<unknown, unknown, unknown> | null = null;
/**
* A reference to the component definition, as retrieved from the registry.
*/
protected readonly def: CompDef<PropsType, ElementsType, EventsType, ActionsType> | null;
/**
* Props that are used to render the component and update the DOM tree.
* This map is constructed from the props provided by the user, but it can be modified by
* the component using the setProp() method.
*/
protected elementProps: Map<keyof PropsType, PropsType[keyof PropsType]>;
/**
* Props that are provided by the component user.
*/
protected props: Readonly<PropsType>;
/**
* A reference to the DOM tree of the current component, and a callback that is called when the
* component is destroyed.
*/
protected renderedDom: CompDom<ElementsType, ActionsType> | null;
/**
* Internally used event listeners that are registerer by the renderer.
* Those events are mounted on the DOM tree of the component by the renderer.
* This map can be used by components that extend the BaseComp to register listeners.
*/
protected rendererEventListeners: Map<EventsType, () => void>;
/**
* Props that are passed to the renderer.
* This map is constructed from the props provided by the user.
*/
protected rendererProps: PropsType;
/**
* A reference to the root element of the current component.
* This could be an HTML element (for most of the cases when the component is rendered in the DOM tree)
* or it could be a document fragment (for cases when the component is rendered in a virtual DOM tree).
* This property is set to null when the component is not rendered.
*/
protected renderingRoot: HTMLElement | DocumentFragment | null;
/**
* Element IDs of the sub-components that are mounted in the DOM tree of the current component.
* The key is the ID of the sub-component and the value is the ID of the element in the DOM tree.
*/
protected subComponentElementIds: Map<string, keyof ElementsType> = new Map();
/**
* Sub-components that are mounted in the DOM tree of the current component.
* This list should be filled by user by calling addPart() method in constructor of the component.
*/
protected subComponents: Map<string, BaseComp<unknown, unknown, unknown, unknown, unknown>> = new Map();
/**
* The context of the current chat component.
*/
private __context: Readonly<ControllerContext<AiMsg>> | null = null;
/**
* A flag that indicates if the current component is destroyed.
* This will prevent the component from being rendered, updated, or used in any way.
*/
private __destroyed: boolean = false;
/**
* A unique identifier of the current component instance.
*/
private readonly __instanceId: string;
/**
* The status of the current component.
*/
private __status: CompStatus = 'unmounted';
/**
* A queue of actions that should be executed on the DOM tree when the component is rendered.
* This queue is used to store actions that are called before the component is rendered.
*/
private actionsOnDomReady: (() => void)[] = [];
private compEventGetter = (eventName: EventsType) => {
if (this.destroyed) {
// Ignore event calls on destroyed components
return () => {
};
}
const callback = this.rendererEventListeners.get(eventName as EventsType);
if (!callback) {
throw new NluxError({
source: this.constructor.name,
message: `Unable to call renderer event "${eventName}" because no matching event listener was found. ` +
`Make sure that the event listener is registered using @CompEventListener() decorator ` +
`in the component model class, and use class methods instead of arrow function attributes.`,
});
}
return callback;
};
protected constructor(context: ControllerContext<AiMsg>, props: PropsType) {
const compId = Object.getPrototypeOf(this).constructor.__compId;
if (!compId) {
throw new NluxUsageError({
source: this.constructor.name,
message: 'Unable to instantiate component: missing compId in implementation. ' +
'Component should be annotated using @Model() to set compId ' +
'before iy can be instantiated.',
});
}
this.def = CompRegistry.retrieve(compId) ?? null;
if (!this.def) {
throw new NluxUsageError({
source: this.constructor.name,
message: `Unable to instantiate component "${compId}" because it\'s not registered. ` +
'Component should be registered using CompRegistry.register(ComponentClass) ' +
'before instantiating a component.',
});
}
this.__instanceId = uid();
this.__destroyed = false;
this.__context = context;
this.renderedDom = null;
this.renderingRoot = null;
this.props = props;
const entries = (props ? Object.entries(props) as unknown : []) as Iterable<unknown>;
this.elementProps = new Map(entries as ([keyof PropsType, PropsType[keyof PropsType]])[]);
this.rendererEventListeners = new Map();
const preDefinedEventListeners: Map<string, string[]> | null = (
this.constructor as unknown as Record<string, unknown>
).__compEventListeners as Map<string, string[]> | null;
if (preDefinedEventListeners) {
preDefinedEventListeners.forEach((methodNames, eventName) => {
methodNames.forEach((methodName) => {
const method = (Object.getPrototypeOf(this))[methodName];
if (typeof method === 'function') {
this.addRendererEventListener(eventName as EventsType, method.bind(this));
} else {
warn(`Unable to set event listener "${eventName}" because method "${methodName}" ` +
`cannot be found on component "${this.constructor.name} at runtime!"`);
}
});
});
}
this.rendererProps = Object.freeze(props) as PropsType;
}
public get destroyed(): boolean {
return this.__destroyed;
}
public get id(): string {
return this.__instanceId;
}
public get rendered(): boolean {
return this.renderedDom !== null;
}
public get root(): HTMLElement | DocumentFragment | null {
this.throwIfDestroyed();
if (!this.renderedDom || !this.renderingRoot) {
return null;
}
return this.renderingRoot;
}
public get status(): CompStatus {
return this.__status;
}
protected get context(): ControllerContext<AiMsg> {
if (!this.__context) {
throw new NluxUsageError({
source: this.constructor.name,
message: 'Unable to get context because it\'s not set',
});
}
return this.__context;
}
public destroy() {
this.destroyComponent();
}
public destroyListItemComponent() {
this.destroyComponent(true);
}
public getProp(name: keyof PropsType): PropsType[keyof PropsType] | null {
this.throwIfDestroyed();
return this.elementProps.get(name) ?? null;
}
/**
* Renders the current component in the DOM tree.
* This method should be called by the component user to render the component. It should only be called once.
* If the user attempts to render a mounted or destroyed component, an error will be thrown.
*
* You can use rendered property to check if the component is rendered before/after calling render().
* You can use destroyed property to check if the component is already destroyed before calling render().
*
* @param root The root element where the component should be rendered.
* @param insertBeforeElement The element before which the component should be inserted. If not provided, the
* component will be appended to the root element. If provided, the component will be inserted before the
* provided element if it exists in the root element.
*/
public render(root: HTMLElement, insertBeforeElement?: HTMLElement) {
if (!this.def) {
return;
}
if (this.destroyed) {
warn(`Unable to render component "${this.def?.id}" because it is already destroyed`);
return;
}
if (this.rendered || this.renderedDom) {
warn(`Unable to render component "${this.def.id}" because it is already rendered`);
return;
}
// IMPORTANT: This is where rendering happens!
// We initially render the component in a virtual root element (document fragment)
// Then we render the sub-components in their respective portals
// Then we append the virtual root element to the actual root element
const virtualRoot = document.createDocumentFragment();
const compId = Object.getPrototypeOf(this).constructor.__compId;
const renderedDom = this.executeRenderer(virtualRoot);
if (!renderedDom) {
throw new NluxError({
source: this.constructor.name,
message: `Unable to render component "${compId}" because renderer returned null`,
});
}
this.renderedDom = renderedDom;
// We render sub-components in their respective portals
for (const [, subComponent] of this.subComponents) {
const portal = this.getSubComponentPortal(subComponent.id);
if (portal) {
this.mountSubComponentToPortal(subComponent.id, portal);
}
}
// We append the virtual root element to the actual root element
domOp(() => {
if (this.destroyed) {
return;
}
if (insertBeforeElement) {
root.insertBefore(virtualRoot, insertBeforeElement);
} else {
root.append(virtualRoot);
}
this.renderingRoot = root;
});
}
updateSubComponent(subComponentId: string, propName: string, newValue: unknown) {
this.throwIfDestroyed();
const subComp = this.subComponents.get(subComponentId) as BaseComp<
AiMsg, PropsType, ElementsType, EventsType, ActionsType
> | undefined;
if (subComp && !subComp.destroyed) {
subComp.setProp(propName as keyof PropsType, newValue as PropsType[keyof PropsType]);
}
}
protected addSubComponent<SubCompPropsType, SubCompElementsType, SubCompEventsType, SubCompActionsType>(
id: string,
subComponent: BaseComp<AiMsg, SubCompPropsType, SubCompElementsType, SubCompEventsType, SubCompActionsType>,
rendererElementId?: keyof ElementsType,
) {
this.throwIfDestroyed();
if (this.subComponents.has(id)) {
throw new NluxUsageError({
source: this.constructor.name,
message: `Unable to add sub-component "${id}" because it already exists`,
});
}
this.subComponents.set(id, subComponent as BaseComp<unknown, unknown, unknown, unknown, unknown>);
if (rendererElementId) {
this.subComponentElementIds.set(id, rendererElementId);
}
if (this.renderedDom) {
const portal = this.getSubComponentPortal(id);
if (portal) {
this.mountSubComponentToPortal(id, portal);
}
}
}
/**
* Executes a DOM action on the current component.
* DOM actions are defined by the renderer and are used to call DOM-defined functions.
* DOM actions should not be used to update the DOM tree. Use setProp() to update the DOM tree.
*
* Example of DOM actions: Focus on an input, scroll to a specific element, select text.
*
* @param actionName
* @param args
* @protected
*/
protected executeDomAction(actionName: keyof ActionsType, ...args: Array<unknown>) {
this.throwIfDestroyed();
if (!this.renderedDom) {
this.actionsOnDomReady.push(() => this.executeDomAction(actionName, ...args));
return;
}
if (!this.renderingRoot) {
throw new NluxError({
source: this.constructor.name,
message: 'Unable to execute DOM action because renderingRoot is not set',
});
}
// Execute the action
const action = (<ActionsType>this.renderedDom.actions)[actionName];
if (!action) {
throw new NluxError({
source: this.constructor.name,
message: `Unable to execute DOM action "${String(actionName)}" because it does not exist`,
});
}
return domOp(() => (action as CallbackFunction)(...args as CallbackArgType[]));
};
protected executeRenderer(root: HTMLElement | DocumentFragment) {
const renderer = this.def?.render;
if (!renderer) {
return null;
}
if (this.renderingRoot) {
throw new NluxError({
source: this.constructor.name,
message: 'Unable to render component because renderingRoot is already set',
});
}
const result = renderer({
appendToRoot: (element: HTMLElement) => {
root.append(element);
// Run pending DOM actions that were queued before the component was rendered
this.runDomActionsQueue();
},
compEvent: this.compEventGetter,
props: this.rendererProps,
context: this.context as ControllerContext<unknown>,
});
if (result) {
// Only keep a reference to the root element of the component on successful rendering.
this.renderingRoot = root;
}
return result;
}
protected removeSubComponent(id: string) {
this.throwIfDestroyed();
domOp(() => {
const subComp = this.subComponents.get(id);
if (subComp) {
// Change sub-component root before deleting it to avoid deleting the root element
subComp.renderingRoot = null;
subComp.destroy();
this.subComponents.delete(id);
}
});
}
protected runDomActionsQueue() {
if (this.actionsOnDomReady.length > 0 && this.rendered) {
const actionsOnDomReady = this.actionsOnDomReady;
this.actionsOnDomReady = [];
for (const action of actionsOnDomReady) {
domOp(() => action());
}
}
}
/**
* Sets a property of the current component.
* This method can be called by the component to change property values.
* New values will be passed to updater function to update the DOM tree.
*
* @param name
* @param value
* @protected
*/
protected setProp(name: keyof PropsType, value: PropsType[keyof PropsType]) {
if (this.destroyed) {
warn(`Unable to set prop "${String(name)}" because component "${this.constructor.name}" is destroyed`);
return;
}
if (!this.elementProps.has(name)) {
warn(`Unable to set prop "${String(name)}" because it does not exist in the component props`);
return;
}
this.schedulePropUpdate(
name,
this.elementProps.get(name)!,
value,
);
this.props = Object.freeze(Object.fromEntries(this.elementProps)) as Readonly<PropsType>;
this.elementProps.set(name, value);
}
protected throwIfDestroyed() {
if (this.__destroyed) {
throw new NluxUsageError({
source: this.constructor.name,
message: 'Unable to call method on destroyed component',
});
}
}
private addRendererEventListener(eventType: EventsType, listener: () => void) {
this.throwIfDestroyed();
if (this.rendererEventListeners.has(eventType)) {
throw new NluxUsageError({
source: this.constructor.name,
message: `Unable to add event listener to rendererEvents "${eventType}" because it already exists`,
});
}
this.rendererEventListeners.set(eventType, listener);
};
private destroyComponent(isListItem = false) {
this.throwIfDestroyed();
this.subComponents.forEach((subComp) => {
subComp.destroy();
});
if (this.renderedDom) {
if (this.renderedDom.elements) {
this.renderedDom.elements = undefined;
}
if (this.renderedDom.actions) {
this.renderedDom.actions = undefined;
}
if (this.renderedDom.onDestroy) {
this.renderedDom.onDestroy();
}
// IMPORTANT:
// Clean up removed DOM elements asynchronously
const renderingRoot = this.renderingRoot;
domOp(() => {
if (!renderingRoot) {
return;
}
if (renderingRoot instanceof DocumentFragment) {
while (renderingRoot.firstChild) {
renderingRoot.removeChild(renderingRoot.firstChild);
}
} else {
if (isListItem) {
renderingRoot.parentElement?.removeChild(renderingRoot);
} else {
emptyInnerHtml(renderingRoot);
}
}
});
this.renderedDom = null;
this.renderingRoot = null;
}
this.__destroyed = true;
this.__context = null;
this.props = undefined as PropsType;
this.elementProps.clear();
this.rendererEventListeners.clear();
this.subComponents.clear();
}
private getSubComponentPortal(id: string): HTMLElement | null {
const subComp = this.subComponents.get(id);
const rendererElementId = this.subComponentElementIds.get(id);
if (!subComp || !rendererElementId) {
return null;
}
const value = (<ElementsType>this.renderedDom?.elements)[rendererElementId];
return value instanceof HTMLElement ? value : null;
}
private mountSubComponentToPortal(subComponentId: string, portal: HTMLElement) {
const subComp = this.subComponents.get(subComponentId);
subComp?.render(portal);
}
private schedulePropUpdate(
propName: keyof PropsType,
currentValue: PropsType[keyof PropsType],
newValue: PropsType[keyof PropsType],
) {
if (!this.renderedDom || !this.def?.update) {
return;
}
const renderedDom = this.renderedDom;
const renderingRoot = this.renderingRoot;
const updater = this.def.update;
if (!renderingRoot) {
return;
}
domOp(() => {
updater({
propName,
currentValue,
newValue,
dom: {
root: renderingRoot,
elements: renderedDom.elements,
actions: renderedDom.actions,
},
updateSubComponent: this.updateSubComponent,
});
});
}
}