diff --git a/packages/arui-scripts-modules/.eslintrc.js b/packages/arui-scripts-modules/.eslintrc.js index 844be1be..5308f6ae 100644 --- a/packages/arui-scripts-modules/.eslintrc.js +++ b/packages/arui-scripts-modules/.eslintrc.js @@ -2,9 +2,17 @@ module.exports = { root: true, extends: ['custom/common'], parserOptions: { - tsconfigRootDir: __dirname, - project: [ - './tsconfig.eslint.json' - ], - }, -}; \ No newline at end of file + tsconfigRootDir: __dirname, + project: [ + './tsconfig.eslint.json', + ], + }, + overrides: [ + { + files: ['**/__tests__/**/*.{ts,tsx}'], + rules: { + 'import/no-extraneous-dependencies': 'off', + }, + } + ], +}; diff --git a/packages/arui-scripts-modules/.gitignore b/packages/arui-scripts-modules/.gitignore index dd87e2d7..1d2a37f5 100644 --- a/packages/arui-scripts-modules/.gitignore +++ b/packages/arui-scripts-modules/.gitignore @@ -1,2 +1,3 @@ node_modules build +coverage diff --git a/packages/arui-scripts-modules/.npmignore b/packages/arui-scripts-modules/.npmignore index 825b96da..7d8e2e02 100644 --- a/packages/arui-scripts-modules/.npmignore +++ b/packages/arui-scripts-modules/.npmignore @@ -1,3 +1,5 @@ src build/tsconfig.tsbuildinfo __tests__ +.turbo +coverage diff --git a/packages/arui-scripts-modules/jest.config.js b/packages/arui-scripts-modules/jest.config.js index 4d0a7325..dc783c72 100644 --- a/packages/arui-scripts-modules/jest.config.js +++ b/packages/arui-scripts-modules/jest.config.js @@ -6,4 +6,8 @@ module.exports = { '/node_modules/', '/build/', ], + collectCoverageFrom: [ + 'src/**/*.{ts,tsx}', + '!src/**/*.d.ts', + ], }; diff --git a/packages/arui-scripts-modules/package.json b/packages/arui-scripts-modules/package.json index 746c94f0..e74d80f3 100644 --- a/packages/arui-scripts-modules/package.json +++ b/packages/arui-scripts-modules/package.json @@ -27,14 +27,20 @@ "react": ">16.18.0" }, "devDependencies": { + "@testing-library/react-hooks": "^8.0.1", "@types/react": "17.0.64", + "@types/uuid": "^9.0.5", "eslint": "^8.20.0", "eslint-config-custom": "workspace:*", "jest": "28.1.3", "jest-environment-jsdom": "^29.6.2", "prettier": "^2.7.1", "react": "17.0.2", + "react-dom": "17.0.2", "ts-jest": "28.0.8", "typescript": "4.9.5" + }, + "dependencies": { + "uuid": "^9.0.1" } } diff --git a/packages/arui-scripts-modules/src/module-loader/__tests__/create-module-fetcher.tests.ts b/packages/arui-scripts-modules/src/module-loader/__tests__/create-module-fetcher.tests.ts new file mode 100644 index 00000000..a250ab36 --- /dev/null +++ b/packages/arui-scripts-modules/src/module-loader/__tests__/create-module-fetcher.tests.ts @@ -0,0 +1,74 @@ +import { createModuleFetcher } from '../create-module-fetcher'; +import { fetchAppManifest } from '../utils/fetch-app-manifest'; + +jest.mock('../utils/fetch-app-manifest'); + +describe('createModuleFetcher', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should fetch the app manifest and return module resources', async () => { + const mockManifest = { + __metadata__: { + version: '1.0', + name: 'Test App', + }, + module1: { + js: 'module1.js', + css: 'module1.css', + mode: 'compat', + }, + }; + + (fetchAppManifest as jest.Mock).mockResolvedValue(mockManifest); + + const baseUrl = 'http://example.com'; + const assetsUrl = '/assets/webpack-assets.json'; + const moduleFetcher = createModuleFetcher({ baseUrl, assetsUrl }); + + const moduleId = 'module1'; + const hostAppId = 'app1'; + + const expectedModuleResources = { + scripts: ['module1.js'], + styles: ['module1.css'], + moduleVersion: '1.0', + appName: 'Test App', + mountMode: 'compat', + moduleState: { + baseUrl: 'http://example.com', + hostAppId: 'app1', + }, + }; + + const moduleResources = await moduleFetcher({ moduleId, hostAppId, params: undefined }); + + expect(fetchAppManifest).toHaveBeenCalledWith('http://example.com/assets/webpack-assets.json'); + expect(moduleResources).toEqual(expectedModuleResources); + }); + + it('should throw an error if module is not found in the manifest', async () => { + const mockManifest = { + __metadata__: { + version: '1.0', + name: 'Test App', + }, + }; + + (fetchAppManifest as jest.Mock).mockResolvedValue(mockManifest); + + const baseUrl = 'http://example.com'; + const assetsUrl = '/assets/webpack-assets.json'; + const moduleFetcher = createModuleFetcher({ baseUrl, assetsUrl }); + + const moduleId = 'module1'; + const hostAppId = 'app1'; + + await expect(moduleFetcher({ moduleId, hostAppId, params: undefined })).rejects.toThrow( + 'Module module1 not found in manifest from http://example.com/assets/webpack-assets.json' + ); + + expect(fetchAppManifest).toHaveBeenCalledWith('http://example.com/assets/webpack-assets.json'); + }); +}); diff --git a/packages/arui-scripts-modules/src/module-loader/__tests__/create-module-loader.tests.ts b/packages/arui-scripts-modules/src/module-loader/__tests__/create-module-loader.tests.ts new file mode 100644 index 00000000..c0262211 --- /dev/null +++ b/packages/arui-scripts-modules/src/module-loader/__tests__/create-module-loader.tests.ts @@ -0,0 +1,307 @@ +import { createModuleLoader } from '../create-module-loader'; +import { getConsumerCounter } from '../utils/consumers-counter'; +import { getCompatModule,getModule } from '../utils/get-module'; +import { mountModuleResources } from '../utils/mount-module-resources'; +import * as cleanGlobal from '../utils/clean-global'; +import * as domUtils from '../utils/dom-utils'; + +jest.mock('../utils/mount-module-resources', () => ({ + mountModuleResources: jest.fn(() => []), +})); + +jest.mock('../utils/get-module', () => ({ + getModule: jest.fn(), + getCompatModule: jest.fn(), +})); + +jest.mock('../utils/consumers-counter', () => ({ + getConsumerCounter: jest.fn(() => ({ + increase: jest.fn(), + decrease: jest.fn(), + getCounter: jest.fn(), + })), +})); + +describe('createModuleLoader', () => { + it('should create a module loader', () => { + const loader = createModuleLoader({ + moduleId: 'test', + hostAppId: 'test', + getModuleResources: jest.fn(), + }); + + expect(loader).toBeDefined(); + expect(loader).toBeInstanceOf(Function); + }); + + it('should call getModuleResources with correct params', async () => { + const getModuleResources = jest.fn(); + const loader = createModuleLoader({ + moduleId: 'test', + hostAppId: 'test', + getModuleResources, + }); + + getModuleResources.mockResolvedValue({ + scripts: [], + styles: [], + moduleState: {}, + mountMode: 'default', + }); + + (getModule as jest.Mock).mockResolvedValueOnce({}); + + await loader({ getResourcesParams: 'paramsToGetResources' }); + + expect(getModuleResources).toHaveBeenCalledWith({ + moduleId: 'test', + hostAppId: 'test', + params: 'paramsToGetResources', + }); + }); + + it('calls the appropriate hooks when loading a module', async () => { + const onBeforeResourcesMount = jest.fn(); + const onBeforeModuleMount = jest.fn(); + const onAfterModuleMount = jest.fn(); + const getModuleResources = jest.fn(); + + const loader = createModuleLoader({ + moduleId: 'test', + hostAppId: 'test', + getModuleResources, + onBeforeResourcesMount, + onBeforeModuleMount, + onAfterModuleMount, + }); + + getModuleResources.mockResolvedValue({ + scripts: [], + styles: [], + moduleState: {}, + mountMode: 'default', + }); + + (getModule as jest.Mock).mockResolvedValueOnce({}); + + await loader({ getResourcesParams: undefined }); + + expect(onBeforeResourcesMount).toHaveBeenCalled(); + expect(onBeforeModuleMount).toHaveBeenCalled(); + expect(onAfterModuleMount).toHaveBeenCalled(); + }); + + it('should call appropriate hooks when unmounting a module', async () => { + const onBeforeModuleUnmount = jest.fn(); + const onAfterModuleUnmount = jest.fn(); + const getModuleResources = jest.fn(); + + const loader = createModuleLoader({ + moduleId: 'test', + hostAppId: 'test', + getModuleResources, + onBeforeModuleUnmount, + onAfterModuleUnmount, + }); + + getModuleResources.mockResolvedValue({ + scripts: [], + styles: [], + moduleState: {}, + mountMode: 'default', + }); + + (getModule as jest.Mock).mockResolvedValueOnce({}); + + const { unmount } = await loader({ getResourcesParams: undefined }); + + unmount(); + + expect(onBeforeModuleUnmount).toHaveBeenCalled(); + expect(onAfterModuleUnmount).toHaveBeenCalled(); + }); + + it('should pass correct params to mountModuleResources', async () => { + const getModuleResources = jest.fn(); + const mountModuleResourcesMock = mountModuleResources as jest.Mock; + const loader = createModuleLoader({ + moduleId: 'test', + hostAppId: 'test', + getModuleResources, + }); + + getModuleResources.mockResolvedValue({ + scripts: [], + styles: [], + moduleState: { + baseUrl: 'https://example.com', + }, + mountMode: 'default', + }); + + (getModule as jest.Mock).mockResolvedValueOnce({}); + + await loader({ getResourcesParams: undefined, cssTargetSelector: '.target' }); + + expect(mountModuleResourcesMock).toHaveBeenCalledWith({ + resourcesTargetNode: undefined, + cssTargetSelector: '.target', + moduleConsumersCounter: expect.anything(), + moduleId: 'test', + scripts: [], + styles: [], + baseUrl: 'https://example.com', + }); + }); + + it('should call getModule with correct params if mountMode is default', async () => { + const getModuleResources = jest.fn(); + const loader = createModuleLoader({ + moduleId: 'test', + hostAppId: 'test', + getModuleResources, + }); + + getModuleResources.mockResolvedValue({ + scripts: [], + styles: [], + moduleState: {}, + mountMode: 'default', + appName: 'AppName', + }); + + (getModule as jest.Mock).mockResolvedValueOnce({}); + + await loader({ getResourcesParams: undefined }); + + expect(getModule).toHaveBeenCalledWith('AppName', 'test'); + }); + + it('should call getCompatModule with correct params if mountMode is compat', async () => { + const getModuleResources = jest.fn(); + const loader = createModuleLoader({ + moduleId: 'test', + hostAppId: 'test', + getModuleResources, + }); + + getModuleResources.mockResolvedValue({ + scripts: [], + styles: [], + moduleState: {}, + mountMode: 'compat', + }); + + (getCompatModule as jest.Mock).mockReturnValueOnce({}); + + await loader({ getResourcesParams: undefined }); + + expect(getCompatModule).toHaveBeenCalledWith('test'); + }); + + it('should throw an error if getModule returns falsy value', async () => { + const getModuleResources = jest.fn(); + const loader = createModuleLoader({ + moduleId: 'test', + hostAppId: 'test', + getModuleResources, + }); + + getModuleResources.mockResolvedValue({ + scripts: [], + styles: [], + moduleState: {}, + mountMode: 'default', + appName: 'AppName', + }); + + (getModule as jest.Mock).mockResolvedValueOnce(undefined); + + await expect(loader({ getResourcesParams: undefined })).rejects.toThrow( + 'Module test is not available' + ); + }); + + describe('module consumers counter', () => { + it('should increase module consumers counter on mount', async () => { + const getModuleResources = jest.fn(); + const loader = createModuleLoader({ + moduleId: 'test', + hostAppId: 'test', + getModuleResources, + }); + const moduleConsumersCounter = (getConsumerCounter as jest.Mock).mock.results[0].value; + + getModuleResources.mockResolvedValue({ + scripts: [], + styles: [], + moduleState: {}, + mountMode: 'default', + appName: 'AppName', + }); + + (getModule as jest.Mock).mockResolvedValueOnce({}); + + await loader({ getResourcesParams: undefined }); + + expect(moduleConsumersCounter.increase).toHaveBeenCalledWith('test'); + }); + + it('should decrease module consumers counter on unmount', async () => { + const getModuleResources = jest.fn(); + const loader = createModuleLoader({ + moduleId: 'test', + hostAppId: 'test', + getModuleResources, + }); + const moduleConsumersCounter = (getConsumerCounter as jest.Mock).mock.results[0].value; + + getModuleResources.mockResolvedValue({ + scripts: [], + styles: [], + moduleState: {}, + mountMode: 'default', + appName: 'AppName', + }); + + (getModule as jest.Mock).mockResolvedValueOnce({}); + + const { unmount } = await loader({ getResourcesParams: undefined }); + + unmount(); + + expect(moduleConsumersCounter.decrease).toHaveBeenCalledWith('test'); + }); + + it('should remove module resources if module consumers counter is 0', async () => { + jest.spyOn(domUtils, 'removeModuleResources'); + jest.spyOn(cleanGlobal, 'cleanGlobal'); + const getModuleResources = jest.fn(); + const loader = createModuleLoader({ + moduleId: 'test', + hostAppId: 'test', + getModuleResources, + }); + const moduleConsumersCounter = (getConsumerCounter as jest.Mock).mock.results[0].value; + + getModuleResources.mockResolvedValue({ + scripts: [], + styles: [], + moduleState: {}, + mountMode: 'default', + appName: 'AppName', + }); + + (getModule as jest.Mock).mockResolvedValueOnce({}); + moduleConsumersCounter.getCounter.mockReturnValueOnce(0); + + const { unmount } = await loader({ getResourcesParams: undefined }); + + unmount(); + + expect(moduleConsumersCounter.getCounter).toHaveBeenCalledWith('test'); + expect(domUtils.removeModuleResources).toHaveBeenCalledWith({ moduleId: 'test', targetNodes: [] }); + expect(cleanGlobal.cleanGlobal).toHaveBeenCalledWith('test'); + }); + }); +}); diff --git a/packages/arui-scripts-modules/src/module-loader/__tests__/create-server-state-module-fetcher.tests.ts b/packages/arui-scripts-modules/src/module-loader/__tests__/create-server-state-module-fetcher.tests.ts new file mode 100644 index 00000000..d4cf16e6 --- /dev/null +++ b/packages/arui-scripts-modules/src/module-loader/__tests__/create-server-state-module-fetcher.tests.ts @@ -0,0 +1,99 @@ +import { createServerStateModuleFetcher } from '../create-server-state-module-fetcher'; +import { urlSegmentWithoutEndSlash } from '../utils/normalize-url-segment'; + +jest.mock('../utils/normalize-url-segment'); + +describe('createServerStateModuleFetcher', () => { + const mockXHR = { + open: jest.fn(), + send: jest.fn(), + setRequestHeader: jest.fn(), + onload: null as null | (() => void), + onerror: null as null | (() => void), + statusText: 'status', + responseText: '{}', + status: 200, + }; + let oldXMLHttpRequest: any; + + beforeAll(() => { + oldXMLHttpRequest = window.XMLHttpRequest; + window.XMLHttpRequest = jest.fn(() => mockXHR) as any; + (urlSegmentWithoutEndSlash as jest.Mock).mockReturnValue('https://test.com'); + }); + + afterAll(() => { + window.XMLHttpRequest = oldXMLHttpRequest; + }); + + it('should create a server state module fetcher correctly', async () => { + const fetchServerResources = createServerStateModuleFetcher({ + baseUrl: 'https://test.com/', + headers: { + 'x-test': 'test', + }, + }); + const fetchParams = { + moduleId: 'test', + hostAppId: 'test', + params: undefined, + }; + + fetchServerResources(fetchParams); + + expect(mockXHR.open).toHaveBeenCalledWith('POST', 'https://test.com/api/getModuleResources', true); + expect(mockXHR.setRequestHeader).toHaveBeenCalledWith('Content-Type', 'application/json'); + expect(mockXHR.setRequestHeader).toHaveBeenCalledWith('x-test', 'test'); + expect(mockXHR.send).toHaveBeenCalledWith(JSON.stringify(fetchParams)); + }); + + it('should handle xhr load event correctly', async () => { + const fetchServerResources = createServerStateModuleFetcher({ + baseUrl: 'https://test.com/', + }); + + const promise = fetchServerResources({ + moduleId: 'test', + hostAppId: 'test', + params: undefined, + }); + + mockXHR.onload!(); + + await expect(promise).resolves.toEqual(JSON.parse(mockXHR.responseText)); + }); + + it('should handle xhr error event correctly', async () => { + const fetchServerResources = createServerStateModuleFetcher({ + baseUrl: 'https://test.com/', + }); + + const promise = fetchServerResources({ + moduleId: 'test', + hostAppId: 'test', + params: undefined, + }); + + mockXHR.onerror!(); + + await expect(promise).rejects.toEqual(new Error(mockXHR.statusText)); + }); + + it('should reject the promise when the response status is not 200', async () => { + const fetchServerResources = createServerStateModuleFetcher({ + baseUrl: 'https://test.com', + }); + + mockXHR.status = 400; + + const promise = fetchServerResources({ + moduleId: 'test', + hostAppId: 'test', + params: undefined, + }); + + mockXHR.onload!(); + + await expect(promise).rejects.toEqual(new Error(mockXHR.statusText)); + }); +}); diff --git a/packages/arui-scripts-modules/src/module-loader/create-module-loader.ts b/packages/arui-scripts-modules/src/module-loader/create-module-loader.ts index 6d0933d8..b320dd7f 100644 --- a/packages/arui-scripts-modules/src/module-loader/create-module-loader.ts +++ b/packages/arui-scripts-modules/src/module-loader/create-module-loader.ts @@ -1,5 +1,5 @@ import { cleanGlobal } from './utils/clean-global'; -import { ConsumersCounter } from './utils/consumers-counter'; +import { getConsumerCounter } from './utils/consumers-counter'; import { removeModuleResources } from './utils/dom-utils'; import { getCompatModule, getModule } from './utils/get-module'; import { mountModuleResources } from './utils/mount-module-resources'; @@ -46,6 +46,8 @@ export type CreateModuleLoaderParams< onAfterModuleUnmount?: ModuleLoaderHookWithModule; }; +const moduleConsumersCounter = getConsumerCounter(); + export function createModuleLoader< ModuleExportType, GetResourcesParams = undefined, @@ -66,8 +68,6 @@ export function createModuleLoader< > { validateUsedWebpackVersion(); - const moduleConsumersCounter = new ConsumersCounter(moduleId); - return async ({ cssTargetSelector, getResourcesParams}) => { // Загружаем описание модуля const moduleResources = await getModuleResources({ @@ -89,7 +89,7 @@ export function createModuleLoader< }); // увеличиваем счетчик потребителей только после добавления скриптов и стилей - moduleConsumersCounter.increase(); + moduleConsumersCounter.increase(moduleId); await onBeforeModuleMount?.(moduleId, moduleResources); @@ -107,11 +107,11 @@ export function createModuleLoader< return { unmount: () => { - moduleConsumersCounter.decrease(); + moduleConsumersCounter.decrease(moduleId); onBeforeModuleUnmount?.(moduleId, moduleResources, loadedModule); - if (moduleConsumersCounter.isAbsent()) { + if (moduleConsumersCounter.getCounter(moduleId) === 0) { // Если на странице больше нет потребителей модуля, то удаляем его ресурсы - скрипты, стили и глобальные переменные removeModuleResources({ moduleId, diff --git a/packages/arui-scripts-modules/src/module-loader/hooks/__tests__/use-module-factory.tests.ts b/packages/arui-scripts-modules/src/module-loader/hooks/__tests__/use-module-factory.tests.ts new file mode 100644 index 00000000..fd1e48e4 --- /dev/null +++ b/packages/arui-scripts-modules/src/module-loader/hooks/__tests__/use-module-factory.tests.ts @@ -0,0 +1,133 @@ +import { renderHook } from '@testing-library/react-hooks'; + +import { FactoryModule } from '../../module-types'; +import { executeModuleFactory,useModuleFactory } from '../use-module-factory'; + +describe('useModuleFactory', () => { + it('should return factory execution result when the loader resolves', async () => { + const moduleExport = jest.fn(); + const loader = jest.fn() + .mockResolvedValue({ module: moduleExport, moduleResources: { moduleState: 'serverState' } }); + const loaderParams = { id: 'my-module' }; + const runParams = { foo: 'bar' }; + + const { result, waitForNextUpdate } = renderHook(() => + useModuleFactory({ loader, loaderParams, runParams }), + ); + + expect(result.current.loadingState).toBe('pending'); + expect(result.current.module).toBeUndefined(); + + await waitForNextUpdate(); + + expect(result.current.loadingState).toBe('fulfilled'); + expect(loader).toHaveBeenCalledWith({ getResourcesParams: loaderParams }); + expect(moduleExport).toHaveBeenCalledWith(runParams, 'serverState'); + }); + + it('should run getFactoryParams with the server state when provided', async () => { + const loader = jest.fn() + .mockResolvedValue({ module: jest.fn(), moduleResources: { moduleState: 'serverState' } }); + const getFactoryParams = jest.fn((serverState) => serverState); + + const { waitForNextUpdate } = renderHook(() => + useModuleFactory({ loader, loaderParams: {}, getFactoryParams }), + ); + + await waitForNextUpdate(); + + expect(getFactoryParams).toHaveBeenCalledWith('serverState'); + }); + + it('should return an error when the loader rejects', async () => { + const error = new Error('Failed to load module'); + const loader = jest.fn(() => Promise.reject(error)); + const loaderParams = { id: 'my-module' }; + + const { result, waitForNextUpdate } = renderHook(() => + useModuleFactory({ loader, loaderParams }), + ); + + expect(result.current.loadingState).toBe('pending'); + expect(result.current.module).toBeUndefined(); + + await waitForNextUpdate(); + + expect(result.current.loadingState).toBe('rejected'); + expect(result.current.module).toBeUndefined(); + expect(loader).toHaveBeenCalledWith({ getResourcesParams: loaderParams }); + }); + + it('should call unmount function of a loader when the component unmounts', async () => { + const unmount = jest.fn(); + const loader = jest.fn() + .mockResolvedValue({ module: jest.fn(), moduleResources: { moduleState: 'serverState' }, unmount }); + const loaderParams = { id: 'my-module' }; + + const { unmount: unmountHook, waitForNextUpdate } = renderHook(() => + useModuleFactory({ loader, loaderParams }), + ); + + await waitForNextUpdate(); + unmountHook(); + + expect(unmount).toHaveBeenCalled(); + }); +}); + +describe('executeModuleFactory', () => { + it('should work when module have default export', async () => { + const mockModule = { + default: jest.fn().mockResolvedValue('result'), + }; + + const result = await executeModuleFactory( + mockModule as unknown as FactoryModule, + { baseUrl: 'example.com', hostAppId: 'app' }, + 'runParams', + ); + + expect(mockModule.default).toHaveBeenCalledWith('runParams', { baseUrl: 'example.com', hostAppId: 'app' }); + expect(result).toBe('result'); + }); + + it('should work when module is a function', async () => { + const mockModule = jest.fn().mockResolvedValue('result'); + + const result = await executeModuleFactory( + mockModule as unknown as FactoryModule, + { baseUrl: 'example.com', hostAppId: 'app' }, + 'runParams', + ); + + expect(mockModule).toHaveBeenCalledWith('runParams', { baseUrl: 'example.com', hostAppId: 'app' }); + expect(result).toBe('result'); + }); + + it('should work when module has a factory field', async () => { + const mockModule = { + factory: jest.fn().mockResolvedValue('result'), + }; + + const result = await executeModuleFactory( + mockModule as unknown as FactoryModule, + { baseUrl: 'example.com', hostAppId: 'app' }, + 'runParams', + ); + + expect(mockModule.factory).toHaveBeenCalledWith('runParams', { baseUrl: 'example.com', hostAppId: 'app' }); + expect(result).toBe('result'); + }); + + it('should throw an error when module has no default export, factory field or is a function', () => { + const mockModule = {}; + + const res = executeModuleFactory( + mockModule as unknown as FactoryModule, + { baseUrl: 'example.com', hostAppId: 'app' }, + 'runParams', + ); + + expect(res).rejects.toThrowError(); + }); +}); diff --git a/packages/arui-scripts-modules/src/module-loader/hooks/__tests__/use-module-loader.tests.ts b/packages/arui-scripts-modules/src/module-loader/hooks/__tests__/use-module-loader.tests.ts new file mode 100644 index 00000000..c6b186ee --- /dev/null +++ b/packages/arui-scripts-modules/src/module-loader/hooks/__tests__/use-module-loader.tests.ts @@ -0,0 +1,87 @@ +import { renderHook } from '@testing-library/react-hooks'; + +import { useModuleLoader } from '../use-module-loader'; + +describe('useModuleLoader', () => { + it('should return the module and resources when the loader resolves', async () => { + const moduleExport = { foo: 'bar' }; + const resources = { css: ['styles.css'], js: ['script.js'] }; + const loader = jest.fn() + .mockResolvedValue({ module: moduleExport, moduleResources: resources }); + const loaderParams = { id: 'my-module' }; + + const { result, waitForNextUpdate } = renderHook(() => + useModuleLoader({ loader: loader as any, loaderParams }), + ); + + expect(result.current.loadingState).toBe('pending'); + expect(result.current.module).toBeUndefined(); + expect(result.current.resources).toBeUndefined(); + + await waitForNextUpdate(); + + expect(result.current.loadingState).toBe('fulfilled'); + expect(result.current.module).toBe(moduleExport); + expect(result.current.resources).toBe(resources); + + expect(loader).toHaveBeenCalledWith({ getResourcesParams: loaderParams }); + }); + + it('should return an error when the loader rejects', async () => { + const error = new Error('Failed to load module'); + const loader = jest.fn().mockRejectedValue(error); + const loaderParams = { id: 'my-module' }; + + const { result, waitForNextUpdate } = renderHook(() => + useModuleLoader({ loader, loaderParams }), + ); + + expect(result.current.loadingState).toBe('pending'); + expect(result.current.module).toBeUndefined(); + expect(result.current.resources).toBeUndefined(); + + await waitForNextUpdate(); + + expect(result.current.loadingState).toBe('rejected'); + expect(result.current.module).toBeUndefined(); + expect(result.current.resources).toBeUndefined(); + }); + + it('should not call the loader again when the loader params change', async () => { + const moduleExport = { foo: 'bar' }; + const resources = { css: ['styles.css'], js: ['script.js'] }; + const loader = jest.fn() + .mockResolvedValue({ module: moduleExport, moduleResources: resources }); + const loaderParams = { id: 'my-module' }; + + const { result, waitForNextUpdate, rerender } = renderHook( + (props) => useModuleLoader(props), + { initialProps: { loader, loaderParams } }, + ); + + await waitForNextUpdate(); + + expect(result.current.loadingState).toBe('fulfilled'); + expect(loader).toHaveBeenCalledWith({ getResourcesParams: loaderParams }); + + rerender({ loader, loaderParams: { id: 'my-module' } }); + + expect(loader).toHaveBeenCalledTimes(1); + }); + + it('should call unmount function from loader when component unmounts', async () => { + const unmount = jest.fn(); + const loader = jest.fn().mockResolvedValue({ unmount }); + const loaderParams = { id: 'my-module' }; + + const { unmount: unmountHook, waitForNextUpdate } = renderHook(() => + useModuleLoader({ loader, loaderParams }), + ); + + await waitForNextUpdate(); + + unmountHook(); + + expect(unmount).toHaveBeenCalled(); + }); +}); diff --git a/packages/arui-scripts-modules/src/module-loader/hooks/__tests__/use-module-mount-target.tests.ts b/packages/arui-scripts-modules/src/module-loader/hooks/__tests__/use-module-mount-target.tests.ts new file mode 100644 index 00000000..8f08ead0 --- /dev/null +++ b/packages/arui-scripts-modules/src/module-loader/hooks/__tests__/use-module-mount-target.tests.ts @@ -0,0 +1,68 @@ +import { act, cleanup,renderHook } from '@testing-library/react-hooks'; + +import { useModuleMountTarget } from '../use-module-mount-target'; + +jest.mock('../use-id', () => ({ + useId: jest.fn(() => 'unique-id'), +})); + +describe('useModuleMountTarget', () => { + afterEach(() => { + cleanup(); + jest.clearAllMocks(); + }); + + it('should return the mount target node when ref triggered', () => { + const targetNode = document.createElement('div'); + const { result } = renderHook( + () => useModuleMountTarget({}) + ); + + expect(result.current.mountTargetNode).toBeUndefined(); + + act(() => { + result.current.afterTargetMountCallback(targetNode); + }); + + const realTarget = targetNode.children[0]; + + expect(result.current.mountTargetNode).toBe(realTarget); + }); + + it('should use the provided createTargetNode function to create the mount target node', () => { + const targetNode = document.createElement('div'); + const createTargetNode = jest.fn(() => targetNode); + const { result } = renderHook( + () => useModuleMountTarget({ createTargetNode }) + ); + + expect(result.current.mountTargetNode).toBeUndefined(); + + act(() => { + result.current.afterTargetMountCallback(document.createElement('div')); + }); + + expect(result.current.mountTargetNode).toBe(targetNode); + expect(createTargetNode).toHaveBeenCalled(); + }); + + it('should create a shadow root when useShadowDom is true', () => { + const targetNode = document.createElement('div'); + const realTarget = document.createElement('div'); + const { result } = renderHook( + () => useModuleMountTarget({ useShadowDom: true, createTargetNode: () => realTarget }) + ); + + expect(result.current.mountTargetNode).toBeUndefined(); + + act(() => { + result.current.afterTargetMountCallback(targetNode); + }); + + expect(targetNode.shadowRoot).toBeDefined(); + expect(targetNode.shadowRoot?.children[0].tagName).toBe('BODY'); + expect(targetNode.shadowRoot?.children[0].children[0]).toBe(realTarget); + expect(result.current.cssTargetSelector).toBe('[data-module-mount-id="unique-id"]'); + expect(result.current.mountTargetNode).toBe(realTarget); + }); +}); diff --git a/packages/arui-scripts-modules/src/module-loader/hooks/__tests__/use-module-mounter.tests.ts b/packages/arui-scripts-modules/src/module-loader/hooks/__tests__/use-module-mounter.tests.ts new file mode 100644 index 00000000..0e1ce189 --- /dev/null +++ b/packages/arui-scripts-modules/src/module-loader/hooks/__tests__/use-module-mounter.tests.ts @@ -0,0 +1,188 @@ +import { cleanup,renderHook } from '@testing-library/react-hooks'; + +import { useModuleMountTarget } from '../use-module-mount-target'; +import { useModuleMounter } from '../use-module-mounter'; + +jest.mock('../use-module-mount-target', () => ({ + useModuleMountTarget: jest.fn() + .mockReturnValue({ + mountTargetNode: undefined, + cssTargetSelector: undefined, + afterTargetMountCallback: jest.fn() + }), +})); + +jest.useFakeTimers(); + +// Мы используем эту функцию чтобы промисы внутри хука не резолвились сразу и мы могли проверить состояние хука +function wait(ms: number, value: T): Promise { + return new Promise(resolve => { + setTimeout(() => resolve(value), ms) + }); +} + +describe('useModuleMounter', () => { + afterEach(() => { + cleanup(); + jest.clearAllMocks(); + }); + + const mountableModule = { + mount: jest.fn(), + unmount: jest.fn(), + }; + const loader = jest.fn(); + const loaderParams = {}; + const runParams = {}; + const createTargetNode = jest.fn(); + const useShadowDom = false; + const moduleServerState = {}; + + it('mount module to element provided by useModuleMountTarget hook', async () => { + const mountTargetNode = document.createElement('div'); + + loader.mockReturnValue(wait(1, { + module: mountableModule, + moduleResources: { + moduleState: moduleServerState, + }, + })) + + const { result, waitForNextUpdate, rerender } = renderHook(() => + useModuleMounter({ + loader, + loaderParams, + runParams, + createTargetNode, + useShadowDom, + }) + ); + + expect(result.current.loadingState).toBe('unknown'); + + (useModuleMountTarget as jest.Mock).mockReturnValueOnce({ + mountTargetNode, + cssTargetSelector: 'head', + afterTargetMountCallback: jest.fn(), + }); + + rerender(); + + expect(result.current.loadingState).toBe('pending'); + expect(loader).toHaveBeenCalledWith({ + getResourcesParams: loaderParams, + cssTargetSelector: 'head', + }); + + jest.advanceTimersByTime(1); + await waitForNextUpdate(); + + expect(result.current.loadingState).toBe('fulfilled'); + expect(mountableModule.mount).toHaveBeenCalledWith( + mountTargetNode, + runParams, + moduleServerState, + ); + }); + + it('should unmount module when component unmounts', async () => { + const loaderUnmount = jest.fn(); + + loader.mockResolvedValueOnce({ + module: mountableModule, + moduleResources: { + moduleState: moduleServerState, + }, + unmount: loaderUnmount, + }); + (useModuleMountTarget as jest.Mock).mockReturnValue({ + mountTargetNode: document.createElement('div'), + cssTargetSelector: 'head', + afterTargetMountCallback: jest.fn(), + }); + + const { unmount, waitForNextUpdate } = renderHook(() => + useModuleMounter({ + loader, + loaderParams, + runParams, + createTargetNode, + useShadowDom, + }) + ); + + await waitForNextUpdate(); + + unmount(); + + expect(mountableModule.unmount).toHaveBeenCalled(); + expect(loaderUnmount).toHaveBeenCalled(); + }); + + it('should not call loader again when loader params change', async () => { + loader.mockResolvedValueOnce({ + module: mountableModule, + moduleResources: { + moduleState: moduleServerState, + }, + unmount: jest.fn(), + }); + (useModuleMountTarget as jest.Mock).mockReturnValue({ + mountTargetNode: document.createElement('div'), + cssTargetSelector: 'head', + afterTargetMountCallback: jest.fn(), + }); + + const { rerender, waitForNextUpdate } = renderHook( + (props) => useModuleMounter(props), + { + initialProps: { + loader, + loaderParams, + runParams, + createTargetNode, + useShadowDom, + }, + } + ); + + await waitForNextUpdate(); + + rerender({ + loader, + loaderParams: { value: 'new' }, + runParams: { value: 'new' }, + createTargetNode, + useShadowDom, + }); + + expect(loader).toHaveBeenCalledTimes(1); + }); + + it('should return rejected state when loader rejects', async () => { + const error = new Error('Failed to load module'); + + loader.mockRejectedValueOnce(error); + (useModuleMountTarget as jest.Mock).mockReturnValue({ + mountTargetNode: document.createElement('div'), + cssTargetSelector: 'head', + afterTargetMountCallback: jest.fn(), + }); + + const { result, waitForNextUpdate } = renderHook(() => + useModuleMounter({ + loader, + loaderParams, + runParams, + createTargetNode, + useShadowDom, + }) + ); + + expect(result.current.loadingState).toBe('pending'); + + await waitForNextUpdate(); + + expect(result.current.loadingState).toBe('rejected'); + }); +}); diff --git a/packages/arui-scripts-modules/src/module-loader/hooks/use-id.ts b/packages/arui-scripts-modules/src/module-loader/hooks/use-id.ts new file mode 100644 index 00000000..f898232e --- /dev/null +++ b/packages/arui-scripts-modules/src/module-loader/hooks/use-id.ts @@ -0,0 +1,12 @@ +import React from 'react'; +import { v4 } from 'uuid'; + +export const useId = (React as any).useId || function useUuid() { + /* + * Utilize useState instead of useMemo because React + * makes no guarantees that the memo store is durable + */ + const id = React.useState(() => v4())[0]; + + return id; +}; diff --git a/packages/arui-scripts-modules/src/module-loader/hooks/use-module-mount-target.ts b/packages/arui-scripts-modules/src/module-loader/hooks/use-module-mount-target.ts index 011537e2..0040fc80 100644 --- a/packages/arui-scripts-modules/src/module-loader/hooks/use-module-mount-target.ts +++ b/packages/arui-scripts-modules/src/module-loader/hooks/use-module-mount-target.ts @@ -1,6 +1,6 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useState } from 'react'; -let mountId = 0; +import { useId } from './use-id'; type UseModuleMountTargetParams = { useShadowDom?: boolean; @@ -14,11 +14,7 @@ export function useModuleMountTarget({ createTargetNode, }: UseModuleMountTargetParams) { const [mountTargetNode, setMountTargetNode] = useState(); - const currentMountId = useMemo(() => { - mountId += 1; - - return `arui-scripts-module-${mountId}`; - }, []); + const currentMountId = useId(); // Мы не можем использовать useRef тут, useRef не будет тригерить ререндер, так как он не меняет ничего // внутри хуков. https://reactjs.org/docs/hooks-faq.html#how-can-i-measure-a-dom-node const afterTargetMountCallback = useCallback((node: HTMLElement | null) => { diff --git a/packages/arui-scripts-modules/src/module-loader/utils/__tests__/consumer-counter.tests.ts b/packages/arui-scripts-modules/src/module-loader/utils/__tests__/consumer-counter.tests.ts index 31cb219e..778e98f3 100644 --- a/packages/arui-scripts-modules/src/module-loader/utils/__tests__/consumer-counter.tests.ts +++ b/packages/arui-scripts-modules/src/module-loader/utils/__tests__/consumer-counter.tests.ts @@ -1,28 +1,24 @@ -import { ConsumersCounter, resetConsumersCounter } from '../consumers-counter'; +import { getConsumerCounter } from '../consumers-counter'; describe('ConsumersCounter', () => { - beforeEach(() => { - resetConsumersCounter(); - }); - - it('isAbsent should return true if module never get increased', () => { - const counter = new ConsumersCounter('test'); + it('getCounter should return 0 if module never get increased', () => { + const counter = getConsumerCounter(); - expect(counter.isAbsent()).toBe(true); + expect(counter.getCounter('test')).toBe(0); }); - it('isAbsent should return false if module get increased', () => { - const counter = new ConsumersCounter('test'); + it('getCounter should return consume counter if module get increased', () => { + const counter = getConsumerCounter(); - counter.increase(); - expect(counter.isAbsent()).toBe(false); + counter.increase('test'); + expect(counter.getCounter('test')).toBe(1); }); - it('isAbsent should return true if module get increased and decreased', () => { - const counter = new ConsumersCounter('test'); + it('getCounter should return 0 if module get increased and decreased', () => { + const counter = getConsumerCounter(); - counter.increase(); - counter.decrease(); - expect(counter.isAbsent()).toBe(true); + counter.increase('test'); + counter.decrease('test'); + expect(counter.getCounter('test')).toBe(0); }); }); diff --git a/packages/arui-scripts-modules/src/module-loader/utils/__tests__/get-module.tests.ts b/packages/arui-scripts-modules/src/module-loader/utils/__tests__/get-module.tests.ts new file mode 100644 index 00000000..de0caac6 --- /dev/null +++ b/packages/arui-scripts-modules/src/module-loader/utils/__tests__/get-module.tests.ts @@ -0,0 +1,85 @@ +/* eslint-disable no-underscore-dangle */ +import { ModuleFederationContainer } from '../../types'; +import { getCompatModule, getModule } from '../get-module'; + +const windowVarName = 'my-container'; + +afterEach(() => { + delete (window as unknown as Record)[windowVarName]; +}) + +describe('getModule', () => { + const globalScope = global as unknown as Record; + const typedWindow = window as unknown as Record; + + beforeEach(() => { + globalScope.__webpack_init_sharing__ = jest.fn(() => Promise.resolve()); + globalScope.__webpack_share_scopes__ = { + default: {}, + }; + }); + + afterEach(() => { + delete globalScope.__webpack_init_sharing__; + delete globalScope.__webpack_share_scopes__; + }); + + it('should return the module factory function', async () => { + const moduleId = 'my-module'; + const factory = jest.fn(() => 'module content'); + const container = { + init: jest.fn(), + get: jest.fn(() => Promise.resolve(factory)), + } as ModuleFederationContainer; + + typedWindow[windowVarName] = container; + + const result = await getModule(windowVarName, moduleId); + + expect(container.init).toHaveBeenCalledWith(expect.any(Object)); + expect(container.get).toHaveBeenCalledWith(moduleId); + expect(factory).toHaveBeenCalled(); + expect(result).toBe('module content'); + }); + + it('should throw an error if the container is not initialized', async () => { + const moduleId = 'my-module'; + + typedWindow[windowVarName] = {} as unknown as ModuleFederationContainer; + + await expect(getModule(windowVarName, moduleId)).rejects.toThrow( + `Cannot load external remote: ${windowVarName}, unable to locate module federation init function`, + ); + }); + + it('should throw an error if the module is not found in the container', async () => { + const moduleId = 'my-module'; + + (window as any)[windowVarName] = { + init: jest.fn(), + get: jest.fn(() => Promise.resolve(undefined)), + }; + + await expect(getModule(windowVarName, moduleId)).rejects.toThrow( + `Cannot load external remote: ${moduleId}, unable to locate module inside a container`, + ); + }); +}); + +describe('getCompatModule', () => { + it('should return the module if it exists', () => { + const module = jest.fn(); + + (window as unknown as Record)[windowVarName] = module; + + expect(getCompatModule(windowVarName)).toBe(module); + }); + + it('should throw an error if the module is not found', () => { + const moduleId = 'my-module'; + + expect(() => getCompatModule(moduleId)).toThrow( + `Cannot load compat module: ${moduleId}, unable to locate module in window`, + ); + }); +}); diff --git a/packages/arui-scripts-modules/src/module-loader/utils/__tests__/normalize-url-segment.tests.ts b/packages/arui-scripts-modules/src/module-loader/utils/__tests__/normalize-url-segment.tests.ts index 6ad960c6..dfdd2bac 100644 --- a/packages/arui-scripts-modules/src/module-loader/utils/__tests__/normalize-url-segment.tests.ts +++ b/packages/arui-scripts-modules/src/module-loader/utils/__tests__/normalize-url-segment.tests.ts @@ -1,6 +1,9 @@ import { normalizeUrlSegment, urlSegmentWithoutEndSlash } from '../normalize-url-segment'; describe('normalizeUrlSegment', () => { + it('should return slash for empty string', () => { + expect(normalizeUrlSegment('')).toBe('/'); + }); it('should return slash for falsy values', () => { expect(normalizeUrlSegment('')).toBe('/'); }); diff --git a/packages/arui-scripts-modules/src/module-loader/utils/__tests__/unwrap-default-export.tests.ts b/packages/arui-scripts-modules/src/module-loader/utils/__tests__/unwrap-default-export.tests.ts new file mode 100644 index 00000000..6bcbe813 --- /dev/null +++ b/packages/arui-scripts-modules/src/module-loader/utils/__tests__/unwrap-default-export.tests.ts @@ -0,0 +1,43 @@ +import { unwrapDefaultExport } from '../unwrap-default-export'; + +describe('unwrapDefaultExport', () => { + it('should return the default export if it exists', () => { + const module = { + default: 'default export', + }; + + const result = unwrapDefaultExport(module); + + expect(result).toBe('default export'); + }); + + it('should return the module export if default export does not exist', () => { + const module = { + otherExport: 'other export', + }; + + const result = unwrapDefaultExport(module); + + expect(result).toBe(module); + }); + + it('should return the module export if default export is null', () => { + const module = { + default: null, + }; + + const result = unwrapDefaultExport(module); + + expect(result).toBe(module); + }); + + it('should return the module export if default export is undefined', () => { + const module = { + default: undefined, + }; + + const result = unwrapDefaultExport(module); + + expect(result).toBe(module); + }); +}); diff --git a/packages/arui-scripts-modules/src/module-loader/utils/consumers-counter.ts b/packages/arui-scripts-modules/src/module-loader/utils/consumers-counter.ts index ed92cb30..3406f5f9 100644 --- a/packages/arui-scripts-modules/src/module-loader/utils/consumers-counter.ts +++ b/packages/arui-scripts-modules/src/module-loader/utils/consumers-counter.ts @@ -1,35 +1,29 @@ -// TODO: remove eslint-disable-next-line -const counters: Record = {}; +export function getConsumerCounter() { + const counters: Record = {}; -export class ConsumersCounter { - moduleId: string; - - constructor(moduleId: string) { - this.moduleId = moduleId; - - // eslint-disable-next-line no-prototype-builtins - if (!counters.hasOwnProperty(moduleId)) { + function ensureCounter(moduleId: string) { + if (!Object.prototype.hasOwnProperty.call(counters, moduleId)) { counters[moduleId] = 0; } } - isAbsent() { - return counters[this.moduleId] === 0; - } - - increase() { - counters[this.moduleId] += 1; - } + return { + increase(moduleId: string) { + ensureCounter(moduleId); + counters[moduleId] += 1; + }, + decrease(moduleId: string) { + ensureCounter(moduleId); + if (counters[moduleId] > 0) { + counters[moduleId] -= 1; + } + }, + getCounter(moduleId: string) { + ensureCounter(moduleId); - decrease() { - if (counters[this.moduleId] > 0) { - counters[this.moduleId] -= 1; + return counters[moduleId]; } - } + }; } -export const resetConsumersCounter = () => { - Object.keys(counters).forEach((moduleId) => { - counters[moduleId] = 0; - }); -}; +export type ConsumersCounter = ReturnType; diff --git a/packages/arui-scripts-modules/src/module-loader/utils/mount-module-resources.ts b/packages/arui-scripts-modules/src/module-loader/utils/mount-module-resources.ts index 083617f3..336dc771 100644 --- a/packages/arui-scripts-modules/src/module-loader/utils/mount-module-resources.ts +++ b/packages/arui-scripts-modules/src/module-loader/utils/mount-module-resources.ts @@ -36,7 +36,7 @@ export async function mountModuleResources({ // Подключаем ресурсы модуля на страницу await Promise.all([ - moduleConsumersCounter.isAbsent() + moduleConsumersCounter.getCounter(moduleId) === 0 ? scriptsFetcher({ moduleId, urls: scripts, @@ -45,7 +45,7 @@ export async function mountModuleResources({ attributes: jsTagsAttributes, }) : Promise.resolve(), - moduleConsumersCounter.isAbsent() + moduleConsumersCounter.getCounter(moduleId) === 0 ? stylesFetcher({ moduleId, urls: styles, diff --git a/yarn.lock b/yarn.lock index 58341a6b..0c37d82e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -137,15 +137,19 @@ __metadata: version: 0.0.0-use.local resolution: "@alfalab/scripts-modules@workspace:packages/arui-scripts-modules" dependencies: + "@testing-library/react-hooks": ^8.0.1 "@types/react": 17.0.64 + "@types/uuid": ^9.0.5 eslint: ^8.20.0 eslint-config-custom: "workspace:*" jest: 28.1.3 jest-environment-jsdom: ^29.6.2 prettier: ^2.7.1 react: 17.0.2 + react-dom: 17.0.2 ts-jest: 28.0.8 typescript: 4.9.5 + uuid: ^9.0.1 peerDependencies: react: ">16.18.0" languageName: unknown @@ -1804,6 +1808,15 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.12.5": + version: 7.23.1 + resolution: "@babel/runtime@npm:7.23.1" + dependencies: + regenerator-runtime: ^0.14.0 + checksum: 0cd0d43e6e7dc7f9152fda8c8312b08321cda2f56ef53d6c22ebdd773abdc6f5d0a69008de90aa41908d00e2c1facb24715ff121274e689305c858355ff02c70 + languageName: node + linkType: hard + "@babel/runtime@npm:^7.20.7": version: 7.22.15 resolution: "@babel/runtime@npm:7.22.15" @@ -4533,6 +4546,28 @@ __metadata: languageName: node linkType: hard +"@testing-library/react-hooks@npm:^8.0.1": + version: 8.0.1 + resolution: "@testing-library/react-hooks@npm:8.0.1" + dependencies: + "@babel/runtime": ^7.12.5 + react-error-boundary: ^3.1.0 + peerDependencies: + "@types/react": ^16.9.0 || ^17.0.0 + react: ^16.9.0 || ^17.0.0 + react-dom: ^16.9.0 || ^17.0.0 + react-test-renderer: ^16.9.0 || ^17.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + react-dom: + optional: true + react-test-renderer: + optional: true + checksum: 7fe44352e920deb5cb1876f80d64e48615232072c9d5382f1e0284b3aab46bb1c659a040b774c45cdf084a5257b8fe463f7e08695ad8480d8a15635d4d3d1f6d + languageName: node + linkType: hard + "@tokenizer/token@npm:^0.3.0": version: 0.3.0 resolution: "@tokenizer/token@npm:0.3.0" @@ -5519,6 +5554,13 @@ __metadata: languageName: node linkType: hard +"@types/uuid@npm:^9.0.5": + version: 9.0.5 + resolution: "@types/uuid@npm:9.0.5" + checksum: 7577940949619768303c0bf0a7cc235fac3cfae1c0bb4a2e85bfb87b2eb1024955ab446f775394d259442cd769b663b6ce43c39bdfc955d946bf833804ddb421 + languageName: node + linkType: hard + "@types/webpack-bundle-analyzer@npm:4.6.0": version: 4.6.0 resolution: "@types/webpack-bundle-analyzer@npm:4.6.0" @@ -17809,6 +17851,17 @@ __metadata: languageName: node linkType: hard +"react-error-boundary@npm:^3.1.0": + version: 3.1.4 + resolution: "react-error-boundary@npm:3.1.4" + dependencies: + "@babel/runtime": ^7.12.5 + peerDependencies: + react: ">=16.13.1" + checksum: f36270a5d775a25c8920f854c0d91649ceea417b15b5bc51e270a959b0476647bb79abb4da3be7dd9a4597b029214e8fe43ea914a7f16fa7543c91f784977f1b + languageName: node + linkType: hard + "react-error-overlay@npm:^6.0.11": version: 6.0.11 resolution: "react-error-overlay@npm:6.0.11" @@ -21169,6 +21222,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:^9.0.1": + version: 9.0.1 + resolution: "uuid@npm:9.0.1" + bin: + uuid: dist/bin/uuid + checksum: 39931f6da74e307f51c0fb463dc2462807531dc80760a9bff1e35af4316131b4fc3203d16da60ae33f07fdca5b56f3f1dd662da0c99fea9aaeab2004780cc5f4 + languageName: node + linkType: hard + "uvu@npm:^0.5.0": version: 0.5.6 resolution: "uvu@npm:0.5.6"