import { configure } from "mobx"; import { destroy } from "mobx-state-tree"; import { render, unmountComponentAtNode } from "react-dom"; import { createRoot } from "react-dom/client"; import camelCase from "lodash/camelCase"; import { LabelStudio as LabelStudioReact } from "./Component"; import App from "./components/App/App"; import { configureStore } from "./configureStore"; import legacyEvents from "./core/External"; import { Hotkey } from "./core/Hotkey"; import defaultOptions from "./defaultOptions"; import { destroy as destroySharedStore } from "./mixins/SharedChoiceStore/mixin"; import { EventInvoker } from "./utils/events"; import { FF_LSDV_4620_3_ML, isFF } from "./utils/feature-flags"; import { cleanDomAfterReact, findReactKey } from "./utils/reactCleaner"; import { isDefined } from "./utils/utilities"; // Extend window interface for TypeScript declare global { interface Window { Htx: any; } } configure({ isolateGlobalState: true, }); type Callback = (...args: any[]) => any; type LSFUser = any; type LSFTask = any; // @todo type LSFOptions = SnapshotIn; // because those options will go as initial values for AppStore // but it's not types yet, so here is some excerpt of its parameters type LSFOptions = Record & { interfaces: string[]; keymap?: any; user?: LSFUser; users?: LSFUser[]; task?: LSFTask; settings?: { forceBottomPanel?: boolean; }; instanceOptions?: { reactVersion?: "v18" | "v17"; }; }; export class LabelStudio { static Component = LabelStudioReact; static instances = new Set(); static destroyAll() { LabelStudio.instances.forEach((inst) => inst.destroy?.()); LabelStudio.instances.clear(); } options: Partial; root: Element | string; store: any; reactRoot: any; destroy: (() => void) | null = () => {}; events = new EventInvoker(); getRootElement(root: Element | string) { let element: Element | null = null; if (typeof root === "string") { element = document.getElementById(root); } else { element = root; } if (!element) { throw new Error(`Root element not found (selector: ${root})`); } return element; } constructor(root: Element | string, userOptions: Partial = {}) { const options = { ...defaultOptions, ...userOptions }; if (options.keymap) { Hotkey.setKeymap(options.keymap); } this.root = root; this.options = options; if (options.instanceOptions?.reactVersion === "v18") { this.createAppV18(); } else { this.createAppV17(); } // @todo whole approach to hotkeys should be rewritten, // @todo but for now we need a way to export Hotkey to different app if (window.Htx) window.Htx.Hotkey = Hotkey; this.supportLegacyEvents(); if (options.instanceOptions?.reactVersion !== "v18") { LabelStudio.instances.add(this); } } on(eventName: string, callback: Callback) { this.events.on(eventName, callback); } off(eventName: string, callback: Callback) { if (isDefined(callback)) { this.events.off(eventName, callback); } else { this.events.removeAll(eventName); } } // This is a temporary solution that allows React 17 to work in the meantime. // and we can update our other usages of LabelStudio to use createRoot, namely tests will likely be affected. async createAppV17() { const { store } = await configureStore(this.options, this.events); const rootElement = this.getRootElement(this.root); this.store = store; window.Htx = this.store; const isRendered = false; const renderApp = () => { if (isRendered) { clearRenderedApp(); } render(, rootElement); }; const clearRenderedApp = () => { if (!rootElement.childNodes?.length) return; const childNodes = [...rootElement.childNodes]; // cleanDomAfterReact needs this key to be sure that cleaning affects only current react subtree const reactKey = findReactKey(childNodes[0]); unmountComponentAtNode(rootElement); /* Unmounting doesn't help with clearing React's fibers but removing the manually helps @see https://github.com/facebook/react/pull/20290 (similar problem) That's maybe not relevant in version 18 */ cleanDomAfterReact(childNodes, reactKey); cleanDomAfterReact([rootElement], reactKey); }; renderApp(); store.setAppControls({ isRendered() { return isRendered; }, render: renderApp, clear: clearRenderedApp, }); this.destroy = () => { if (isFF(FF_LSDV_4620_3_ML)) { clearRenderedApp(); } destroySharedStore(); if (isFF(FF_LSDV_4620_3_ML)) { /* It seems that destroying children separately helps GC to collect garbage ... */ this.store.selfDestroy(); } destroy(this.store); Hotkey.unbindAll(); if (isFF(FF_LSDV_4620_3_ML)) { /* ... as well as nulling all these this.store */ this.store = null; this.destroy = null; LabelStudio.instances.delete(this); } }; } // To support React 18 properly, we need to use createRoot // and render the app with it, and properly unmount it and cleanup all references async createAppV18() { const { store } = await configureStore(this.options, this.events); const rootElement = this.getRootElement(this.root); this.store = store; window.Htx = this.store; let isRendered = false; const renderApp = () => { if (isRendered) { clearRenderedApp(); } this.reactRoot = createRoot(rootElement); const AppComponent = App as any; this.reactRoot.render(); isRendered = true; }; const clearRenderedApp = () => { if (this.reactRoot && isRendered) { this.reactRoot.unmount(); this.reactRoot = null; isRendered = false; } }; renderApp(); store.setAppControls({ isRendered() { return isRendered; }, render: renderApp, clear: clearRenderedApp, }); this.destroy = () => { // Clear rendered app clearRenderedApp(); // Destroy shared store destroySharedStore(); // Destroy store destroy(this.store); // Unbind all hotkeys Hotkey.unbindAll(); // Clear references this.store = null; window.Htx = null; this.destroy = null; }; } supportLegacyEvents() { const keys = Object.keys(legacyEvents); keys.forEach((key) => { const callback = this.options[key]; if (isDefined(callback)) { const eventName = camelCase(key.replace(/^on/, "")); this.events.on(eventName, callback); } }); } }