├── CHANGELOG.md ├── CODEOWNERS ├── packages ├── build-utils │ ├── src │ │ ├── index.ts │ │ └── executors │ │ │ ├── sync-dependencies │ │ │ ├── schema.d.ts │ │ │ ├── executor.spec.ts │ │ │ ├── schema.json │ │ │ └── executor.ts │ │ │ └── builder │ │ │ ├── schema.d.ts │ │ │ ├── schema.json │ │ │ ├── executor.spec.ts │ │ │ └── executor.ts │ ├── tsconfig.lib.json │ ├── tsconfig.spec.json │ ├── jest.config.ts │ ├── package.json │ ├── tsconfig.json │ ├── executors.json │ ├── .eslintrc.json │ └── project.json ├── core │ ├── src │ │ ├── index.ts │ │ ├── warnDuplicatePkg.ts │ │ ├── createSharedStore.ts │ │ └── scalprum.test.ts │ ├── tsconfig.cjs.json │ ├── tsconfig.spec.json │ ├── jest.config.ts │ ├── tsconfig.esm.json │ ├── package.json │ ├── tsconfig.json │ ├── .eslintrc.json │ ├── project.json │ └── CHANGELOG.md ├── react-core │ ├── src │ │ ├── TestComponent.tsx │ │ ├── use-scalprum.ts │ │ ├── prefetch-context.tsx │ │ ├── scalprum-context.ts │ │ ├── ensureImmutability.ts │ │ ├── useGetState.ts │ │ ├── index.ts │ │ ├── use-module.ts │ │ ├── useSubscribeStore.ts │ │ ├── async-loader.tsx │ │ ├── use-prefetch.ts │ │ ├── default-error-component.tsx │ │ ├── remote-hooks-types.ts │ │ ├── __snapshots__ │ │ │ └── scalprum-component.test.tsx.snap │ │ ├── use-load-module.ts │ │ ├── ScalprumProvider.cy.tsx │ │ ├── use-remote-hook.ts │ │ ├── use-remote-hook-manager.ts │ │ ├── scalprum-provider.tsx │ │ └── remote-hook-provider.tsx │ ├── tsconfig.cjs.json │ ├── cypress │ │ ├── fixtures │ │ │ └── example.json │ │ ├── support │ │ │ ├── component-index.html │ │ │ ├── component.ts │ │ │ └── commands.ts │ │ └── tsconfig.json │ ├── tsconfig.spec.json │ ├── jest.config.ts │ ├── tsconfig.esm.json │ ├── tsconfig.json │ ├── package.json │ ├── .eslintrc.json │ ├── project.json │ ├── cypress.config.js │ ├── CHANGELOG.md │ └── docs │ │ └── remote-hook-provider.md └── react-test-utils │ ├── src │ ├── overrides.ts │ └── index.tsx │ ├── tsconfig.cjs.json │ ├── tsconfig.spec.json │ ├── jest.config.ts │ ├── .eslintrc.json │ ├── tsconfig.esm.json │ ├── package.json │ ├── tsconfig.json │ ├── project.json │ └── CHANGELOG.md ├── examples ├── test-app │ ├── src │ │ ├── assets │ │ │ └── .gitkeep │ │ ├── main.ts │ │ ├── favicon.ico │ │ ├── components │ │ │ ├── LoadingComponent.d.ts │ │ │ └── LoadingComponent.tsx │ │ ├── modules │ │ │ ├── errorModule.tsx │ │ │ ├── nestedModule.tsx │ │ │ ├── moduleTwo.tsx │ │ │ ├── moduleFour.tsx │ │ │ ├── moduleThree.tsx │ │ │ ├── moduleOne.tsx │ │ │ ├── SDKComponent.tsx │ │ │ └── preLoad.tsx │ │ ├── bootstrap.tsx │ │ ├── routes │ │ │ ├── NotFoundError.tsx │ │ │ ├── RuntimeErrorRoute.tsx │ │ │ ├── UseModuleLoading.tsx │ │ │ ├── RootRoute.tsx │ │ │ ├── ApiUpdates.tsx │ │ │ ├── SDKModules.tsx │ │ │ └── LegacyModules.tsx │ │ ├── index.html │ │ ├── app │ │ │ ├── app.spec.tsx │ │ │ └── app.tsx │ │ ├── layouts │ │ │ └── RootLayout.tsx │ │ └── entry.tsx │ ├── CHANGELOG.md │ ├── webpack.config.prod.ts │ ├── .babelrc │ ├── jest.config.ts │ ├── .eslintrc.json │ ├── tsconfig.json │ ├── tsconfig.spec.json │ ├── tsconfig.app.json │ ├── webpack.config.ts │ └── project.json └── test-app-e2e │ ├── CHANGELOG.md │ ├── src │ ├── support │ │ ├── app.po.ts │ │ ├── e2e.ts │ │ └── commands.ts │ ├── fixtures │ │ └── example.json │ └── e2e │ │ ├── app.cy.ts │ │ └── test-app │ │ ├── use-module-loading.cy.ts │ │ ├── named-export-scalprum-component.cy.ts │ │ ├── scalprum-api.cy.ts │ │ ├── sdk-plugin-loading.cy.ts │ │ ├── module-prefetch-data.cy.ts │ │ ├── module-loading-errors.cy.ts │ │ └── remote-hooks.cy.ts │ ├── tsconfig.json │ ├── .eslintrc.json │ ├── cypress.config.ts │ └── project.json ├── .eslintignore ├── federation-cdn-mock ├── CHANGELOG.md ├── src │ ├── index.tsx │ └── modules │ │ ├── errorModule.tsx │ │ ├── nestedModule.tsx │ │ ├── moduleTwo.tsx │ │ ├── moduleFour.tsx │ │ ├── moduleThree.tsx │ │ ├── apiModule.tsx │ │ ├── useCounterHook.tsx │ │ ├── delayedModule.tsx │ │ ├── useApiHook.tsx │ │ ├── useTimerHook.tsx │ │ ├── moduleOne.tsx │ │ ├── SDKComponent.tsx │ │ └── preLoad.tsx ├── .babelrc ├── tsconfig.json ├── .eslintrc.json ├── jest.config.ts ├── package.json └── webpack.config.js ├── .husky └── commit-msg ├── commitlint.config.js ├── .prettierignore ├── jest.preset.js ├── .commitlintrc.json ├── jest.config.ts ├── .npmrc ├── .prettierrc ├── .editorconfig ├── .github ├── actions │ ├── lint │ │ └── action.yaml │ ├── test-unit │ │ └── action.yaml │ ├── cypress-cache │ │ └── action.yaml │ ├── webpack-cache │ │ └── action.yaml │ ├── node-cache │ │ └── action.yaml │ ├── test-component │ │ └── action.yaml │ ├── test-e2e │ │ └── action.yaml │ └── release │ │ └── action.yaml └── workflows │ └── ci.yml ├── .gitignore ├── .verdaccio └── config.yml ├── tsconfig.base.json ├── dev-script.js ├── .eslintrc.json ├── e2e-script.js ├── nx.json └── package.json /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @scalprum/admins 2 | -------------------------------------------------------------------------------- /packages/build-utils/src/index.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/test-app/src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | test-app/dist 3 | -------------------------------------------------------------------------------- /examples/test-app/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | -------------------------------------------------------------------------------- /federation-cdn-mock/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx --no -- commitlint --edit $1 2 | -------------------------------------------------------------------------------- /examples/test-app-e2e/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | -------------------------------------------------------------------------------- /examples/test-app/src/main.ts: -------------------------------------------------------------------------------- 1 | import('./bootstrap'); 2 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | export default {extends: ['@commitlint/config-conventional']}; 2 | -------------------------------------------------------------------------------- /examples/test-app-e2e/src/support/app.po.ts: -------------------------------------------------------------------------------- 1 | export const getGreeting = () => cy.get('h1'); 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Add files here to ignore them from prettier formatting 2 | /dist 3 | /coverage 4 | /.nx/cache -------------------------------------------------------------------------------- /examples/test-app/webpack.config.prod.ts: -------------------------------------------------------------------------------- 1 | import config from './webpack.config'; 2 | 3 | export default config; 4 | -------------------------------------------------------------------------------- /jest.preset.js: -------------------------------------------------------------------------------- 1 | const nxPreset = require('@nx/jest/preset').default; 2 | 3 | module.exports = { ...nxPreset }; 4 | -------------------------------------------------------------------------------- /examples/test-app/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scalprum/scaffolding/HEAD/examples/test-app/src/favicon.ico -------------------------------------------------------------------------------- /federation-cdn-mock/src/index.tsx: -------------------------------------------------------------------------------- 1 | // just a mock for webpack 2 | // it tends to generate empty assets on CI without explicit entry 3 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './scalprum'; 2 | export * from './createSharedStore'; 3 | export * from './warnDuplicatePkg'; 4 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"], 3 | "rules": { 4 | "body-max-line-length": [0] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/core/tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.esm.json", 3 | "compilerOptions": { 4 | "module": "CommonJS", 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import { getJestProjectsAsync } from '@nx/jest'; 2 | 3 | export default async () => ({ 4 | projects: await getJestProjectsAsync(), 5 | }); 6 | -------------------------------------------------------------------------------- /examples/test-app/src/components/LoadingComponent.d.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | declare const LoadingComponent: React.FC; 3 | export default LoadingComponent; 4 | -------------------------------------------------------------------------------- /packages/react-core/src/TestComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const TestComponent = () => Test; 4 | 5 | export default TestComponent; 6 | -------------------------------------------------------------------------------- /examples/test-app/src/modules/errorModule.tsx: -------------------------------------------------------------------------------- 1 | const ErrorModule = () => { 2 | throw new Error('Synthetic error message'); 3 | }; 4 | 5 | export default ErrorModule; 6 | -------------------------------------------------------------------------------- /federation-cdn-mock/src/modules/errorModule.tsx: -------------------------------------------------------------------------------- 1 | const ErrorModule = () => { 2 | throw new Error('Synthetic error message'); 3 | }; 4 | 5 | export default ErrorModule; 6 | -------------------------------------------------------------------------------- /packages/build-utils/src/executors/sync-dependencies/schema.d.ts: -------------------------------------------------------------------------------- 1 | export interface SyncDependenciesExecutorSchema { 2 | remote?: string; 3 | baseBranch?: string; 4 | } 5 | -------------------------------------------------------------------------------- /packages/react-test-utils/src/overrides.ts: -------------------------------------------------------------------------------- 1 | declare module 'whatwg-fetch' { 2 | function fetch(input: RequestInfo | URL, init?: RequestInit | undefined): Promise; 3 | } 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | # Disable legacy peer deps to prevent issues with dev dependency removal from lockfile 2 | # This ensures proper lockfile management during releases 3 | legacy-peer-deps=false 4 | -------------------------------------------------------------------------------- /packages/build-utils/src/executors/builder/schema.d.ts: -------------------------------------------------------------------------------- 1 | import { BuilderExecutorSchemaType } from './executor'; 2 | 3 | export type BuilderExecutorSchema = BuilderExecutorSchemaType; 4 | -------------------------------------------------------------------------------- /packages/react-core/tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.esm.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /examples/test-app/src/modules/nestedModule.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const NestedModule = () => { 4 | return
Nested Module
; 5 | }; 6 | 7 | export default NestedModule; 8 | -------------------------------------------------------------------------------- /federation-cdn-mock/src/modules/nestedModule.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const NestedModule = () => { 4 | return
Nested Module
; 5 | }; 6 | 7 | export default NestedModule; 8 | -------------------------------------------------------------------------------- /packages/react-test-utils/tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.esm.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /packages/build-utils/src/executors/sync-dependencies/executor.spec.ts: -------------------------------------------------------------------------------- 1 | describe('SyncDependencies Executor', () => { 2 | it('can run', async () => { 3 | expect(true).toBe(true); 4 | }); 5 | }); 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 150, 3 | "arrowParens": "always", 4 | "semi": true, 5 | "tabWidth": 2, 6 | "singleQuote": true, 7 | "jsxSingleQuote": false, 8 | "bracketSpacing": true 9 | } 10 | -------------------------------------------------------------------------------- /examples/test-app-e2e/src/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /packages/react-core/cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /examples/test-app/src/components/LoadingComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const LoadingComponent: React.FC = () => { 4 | return
Super duper loading
; 5 | }; 6 | 7 | export default LoadingComponent; 8 | -------------------------------------------------------------------------------- /examples/test-app-e2e/src/e2e/app.cy.ts: -------------------------------------------------------------------------------- 1 | describe('shell-e2e', () => { 2 | beforeEach(() => cy.visit('/')); 3 | 4 | it('should display welcome message', () => { 5 | cy.contains(/Scalprum testing page/).should('exist'); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /examples/test-app/src/modules/moduleTwo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const ModuleTwo = () => { 4 | return ( 5 |
6 |

Module two remote component

7 |
8 | ); 9 | }; 10 | 11 | export default ModuleTwo; 12 | -------------------------------------------------------------------------------- /examples/test-app/src/modules/moduleFour.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const ModuleFour = () => { 4 | return ( 5 |
6 |

Module four remote component

7 |
8 | ); 9 | }; 10 | 11 | export default ModuleFour; 12 | -------------------------------------------------------------------------------- /federation-cdn-mock/src/modules/moduleTwo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const ModuleTwo = () => { 4 | return ( 5 |
6 |

Module two remote component

7 |
8 | ); 9 | }; 10 | 11 | export default ModuleTwo; 12 | -------------------------------------------------------------------------------- /federation-cdn-mock/src/modules/moduleFour.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const ModuleFour = () => { 4 | return ( 5 |
6 |

Module four remote component

7 |
8 | ); 9 | }; 10 | 11 | export default ModuleFour; 12 | -------------------------------------------------------------------------------- /examples/test-app/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@nx/react/babel", 5 | { 6 | "runtime": "automatic", 7 | "importSource": "@emotion/react" 8 | } 9 | ] 10 | ], 11 | "plugins": ["@emotion/babel-plugin"] 12 | } 13 | -------------------------------------------------------------------------------- /federation-cdn-mock/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@nx/react/babel", 5 | { 6 | "runtime": "automatic", 7 | "importSource": "@emotion/react" 8 | } 9 | ] 10 | ], 11 | "plugins": ["@emotion/babel-plugin"] 12 | } 13 | -------------------------------------------------------------------------------- /packages/build-utils/src/executors/builder/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/schema", 3 | "version": 2, 4 | "title": "Builder executor", 5 | "description": "", 6 | "type": "object", 7 | "properties": {}, 8 | "required": [] 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /examples/test-app/src/bootstrap.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import Entry from './entry'; 4 | 5 | const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); 6 | root.render( 7 | 8 | 9 | , 10 | ); 11 | -------------------------------------------------------------------------------- /packages/build-utils/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "declaration": true, 6 | "types": ["node"] 7 | }, 8 | "include": ["src/**/*.ts"], 9 | "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /packages/react-test-utils/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /examples/test-app/src/routes/NotFoundError.tsx: -------------------------------------------------------------------------------- 1 | import { ScalprumComponent } from '@scalprum/react-core'; 2 | import React from 'react'; 3 | 4 | const NotFoundError = () => { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | }; 11 | 12 | export default NotFoundError; 13 | -------------------------------------------------------------------------------- /examples/test-app-e2e/src/e2e/test-app/use-module-loading.cy.ts: -------------------------------------------------------------------------------- 1 | describe('UseModule loading callback', () => { 2 | beforeEach(() => { 3 | cy.handleMetaError(); 4 | }); 5 | it('should display SDK inbox text', () => { 6 | cy.visit('http://localhost:4200/use-module'); 7 | cy.contains('SDK Inbox').should('exist'); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /packages/core/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": [ 9 | "jest.config.ts", 10 | "src/**/*.test.ts", 11 | "src/**/*.spec.ts", 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /federation-cdn-mock/src/modules/moduleThree.tsx: -------------------------------------------------------------------------------- 1 | import { ScalprumComponent } from '@scalprum/react-core'; 2 | 3 | const ModuleThree = () => { 4 | return ( 5 |
6 |

Module three remote component

7 | 8 |
9 | ); 10 | }; 11 | 12 | export default ModuleThree; 13 | -------------------------------------------------------------------------------- /packages/build-utils/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": [ 9 | "jest.config.ts", 10 | "src/**/*.test.ts", 11 | "src/**/*.spec.ts", 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /packages/core/jest.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export default { 3 | displayName: '@scalprum/core', 4 | preset: '../../jest.preset.js', 5 | transform: { 6 | '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], 7 | }, 8 | moduleFileExtensions: ['ts', 'js', 'html'], 9 | coverageDirectory: '../../coverage/packages/core', 10 | }; 11 | -------------------------------------------------------------------------------- /packages/build-utils/jest.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export default { 3 | displayName: 'build-utils', 4 | preset: '../../jest.preset.js', 5 | transform: { 6 | '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], 7 | }, 8 | moduleFileExtensions: ['ts', 'js', 'html'], 9 | coverageDirectory: '../../coverage/packages/build-utils', 10 | }; 11 | -------------------------------------------------------------------------------- /packages/build-utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@scalprum/build-utils", 3 | "version": "0.0.1", 4 | "dependencies": { 5 | "@nx/devkit": "21.6.3", 6 | "semver": "^7.5.4", 7 | "tslib": "^2.0.0", 8 | "zod": "^3.22.4" 9 | }, 10 | "type": "commonjs", 11 | "main": "./src/index.js", 12 | "typings": "./src/index.d.ts", 13 | "executors": "./executors.json" 14 | } 15 | -------------------------------------------------------------------------------- /packages/build-utils/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "esModuleInterop": true 6 | }, 7 | "files": [], 8 | "include": [], 9 | "references": [ 10 | { 11 | "path": "./tsconfig.lib.json" 12 | }, 13 | { 14 | "path": "./tsconfig.spec.json" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.github/actions/lint/action.yaml: -------------------------------------------------------------------------------- 1 | name: Lint project 2 | description: verify linting rules 3 | runs: 4 | using: "composite" 5 | steps: 6 | - uses: nrwl/nx-set-shas@v4 7 | - uses: './.github/actions/node-cache' 8 | - name: Install deps 9 | shell: bash 10 | run: npm i 11 | - name: Lint affected 12 | shell: bash 13 | run: npx nx affected -t lint 14 | 15 | -------------------------------------------------------------------------------- /examples/test-app/src/modules/moduleThree.tsx: -------------------------------------------------------------------------------- 1 | import { ScalprumComponent } from '@scalprum/react-core'; 2 | import React from 'react'; 3 | 4 | const ModuleThree = () => { 5 | return ( 6 |
7 |

Module three remote component

8 | 9 |
10 | ); 11 | }; 12 | 13 | export default ModuleThree; 14 | -------------------------------------------------------------------------------- /federation-cdn-mock/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react-jsx", 4 | "allowJs": false, 5 | "esModuleInterop": false, 6 | "allowSyntheticDefaultImports": true, 7 | "strict": true, 8 | "jsxImportSource": "@emotion/react", 9 | "module": "esnext", 10 | }, 11 | "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"] 12 | } 13 | -------------------------------------------------------------------------------- /packages/build-utils/src/executors/sync-dependencies/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/schema", 3 | "version": 2, 4 | "title": "SyncDependencies executor", 5 | "description": "", 6 | "type": "object", 7 | "properties": { 8 | "textToEcho": { 9 | "type": "string", 10 | "description": "Text To Echo" 11 | } 12 | }, 13 | "required": [] 14 | } 15 | -------------------------------------------------------------------------------- /packages/core/tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "declaration": true, 6 | "types": ["node"], 7 | "module": "ES2015", 8 | "target": "ES5", 9 | "rootDir": "./src", 10 | }, 11 | "include": ["src/**/*.ts"], 12 | "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /examples/test-app/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Shell 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /packages/react-test-utils/jest.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export default { 3 | displayName: '@scalprum/react-test-utils', 4 | preset: '../../jest.preset.js', 5 | transform: { 6 | '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], 7 | }, 8 | moduleFileExtensions: ['ts', 'js', 'html'], 9 | coverageDirectory: '../../coverage/packages/react-test-utils', 10 | }; 11 | -------------------------------------------------------------------------------- /.github/actions/test-unit/action.yaml: -------------------------------------------------------------------------------- 1 | name: Unit tests 2 | description: verify unit tests 3 | runs: 4 | using: "composite" 5 | steps: 6 | - uses: nrwl/nx-set-shas@v4 7 | - uses: './.github/actions/node-cache' 8 | - name: Install deps 9 | shell: bash 10 | run: npm i 11 | - name: Test affected 12 | shell: bash 13 | run: npx nx affected -t test --configuration=ci 14 | 15 | -------------------------------------------------------------------------------- /examples/test-app-e2e/src/e2e/test-app/named-export-scalprum-component.cy.ts: -------------------------------------------------------------------------------- 1 | describe('SDK module loading', () => { 2 | beforeEach(() => { 3 | cy.handleMetaError(); 4 | }); 5 | it('should show data from prefetch', () => { 6 | cy.visit('http://localhost:4200/sdk'); 7 | 8 | // check if the component using named export is rendered 9 | cy.get('#named-component').should('exist'); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /packages/react-core/cypress/support/component-index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | @scalprum/react-core Components App 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/react-core/cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "outDir": "../../dist/out-tsc", 6 | "module": "commonjs", 7 | "types": ["cypress", "node"], 8 | "sourceMap": false 9 | }, 10 | "include": ["**/*.ts", "**/*.js", "../cypress.config.ts", "../**/*.cy.ts", "../**/*.cy.tsx", "../**/*.cy.js", "../**/*.cy.jsx", "../**/*.d.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /federation-cdn-mock/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["plugin:@nx/react", "../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /examples/test-app/jest.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export default { 3 | displayName: 'shell', 4 | preset: '../../jest.preset.js', 5 | transform: { 6 | '^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '@nx/react/plugins/jest', 7 | '^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nx/react/babel'] }], 8 | }, 9 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], 10 | coverageDirectory: '../../coverage/examples/test-app', 11 | }; 12 | -------------------------------------------------------------------------------- /examples/test-app/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["plugin:@nx/react", "../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*", "dist/**"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /federation-cdn-mock/jest.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export default { 3 | displayName: 'shell', 4 | preset: '../../jest.preset.js', 5 | transform: { 6 | '^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '@nx/react/plugins/jest', 7 | '^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nx/react/babel'] }], 8 | }, 9 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], 10 | coverageDirectory: '../../coverage/examples/test-app', 11 | }; 12 | -------------------------------------------------------------------------------- /.github/actions/cypress-cache/action.yaml: -------------------------------------------------------------------------------- 1 | name: Cypress runner cache 2 | description: Retrieve and cache the cypress runner 3 | runs: 4 | using: "composite" 5 | steps: 6 | # cache cypress runner 7 | - uses: actions/cache@v4 8 | id: cypress-cache 9 | with: 10 | path: /home/runner/.cache/Cypress 11 | key: cypress-runner-cache-${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} 12 | -------------------------------------------------------------------------------- /packages/react-core/src/use-scalprum.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { ScalprumContext, ScalprumState } from './scalprum-context'; 3 | 4 | export function useScalprum>>(selector?: (state: ScalprumState) => T): T { 5 | const state = useContext(ScalprumContext); 6 | if (typeof selector === 'function') { 7 | return selector(state); 8 | } 9 | 10 | return state as unknown as T; 11 | } 12 | -------------------------------------------------------------------------------- /.github/actions/webpack-cache/action.yaml: -------------------------------------------------------------------------------- 1 | name: Webpack dev server cache 2 | description: Cache for e2e test runs 3 | runs: 4 | using: "composite" 5 | steps: 6 | # cache node modules for all jobs to use 7 | - uses: actions/cache@v4 8 | id: webpack-cache 9 | with: 10 | path: | 11 | **/.webpack-cache 12 | key: webpack-cache-${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} 13 | -------------------------------------------------------------------------------- /.github/actions/node-cache/action.yaml: -------------------------------------------------------------------------------- 1 | name: Node modules cache 2 | description: Retrieve and cache project node_modules 3 | runs: 4 | using: "composite" 5 | steps: 6 | # cache node modules for all jobs to use 7 | - uses: actions/cache@v4 8 | id: node_modules-cache 9 | with: 10 | path: | 11 | **/node_modules 12 | key: install-cache-${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} 13 | -------------------------------------------------------------------------------- /examples/test-app-e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "outDir": "../../dist/out-tsc", 6 | "module": "commonjs", 7 | "types": ["cypress", "node"], 8 | "sourceMap": false 9 | }, 10 | "include": [ 11 | "**/*.ts", 12 | "**/*.js", 13 | "cypress.config.ts", 14 | "**/*.cy.ts", 15 | "**/*.cy.tsx", 16 | "**/*.cy.js", 17 | "**/*.cy.jsx", 18 | "**/*.d.ts" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /examples/test-app/src/routes/RuntimeErrorRoute.tsx: -------------------------------------------------------------------------------- 1 | import { ScalprumComponent } from '@scalprum/react-core'; 2 | import React from 'react'; 3 | import LoadingComponent from '../components/LoadingComponent'; 4 | 5 | const RuntimeErrorRoute = () => { 6 | return ( 7 |
8 |

Runtime error route

9 | 10 |
11 | ); 12 | }; 13 | 14 | export default RuntimeErrorRoute; 15 | -------------------------------------------------------------------------------- /federation-cdn-mock/src/modules/apiModule.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useScalprum } from '@scalprum/react-core'; 3 | 4 | export const ApiConsumer = () => { 5 | const { api } = useScalprum(); 6 | return
API consumer isBeta: {`${api.chrome.isBeta()}`}
; 7 | } 8 | 9 | export const ApiChanger = () => { 10 | const { api } = useScalprum(); 11 | return
API changer:
; 12 | } 13 | -------------------------------------------------------------------------------- /packages/build-utils/executors.json: -------------------------------------------------------------------------------- 1 | { 2 | "executors": { 3 | "sync-dependencies": { 4 | "implementation": "./src/executors/sync-dependencies/executor", 5 | "schema": "./src/executors/sync-dependencies/schema.json", 6 | "description": "sync-dependencies executor" 7 | }, 8 | "builder": { 9 | "implementation": "./src/executors/builder/executor", 10 | "schema": "./src/executors/builder/schema.json", 11 | "description": "builder executor" 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.github/actions/test-component/action.yaml: -------------------------------------------------------------------------------- 1 | name: Components tests 2 | description: verify component tests 3 | runs: 4 | using: "composite" 5 | steps: 6 | - uses: nrwl/nx-set-shas@v4 7 | - uses: './.github/actions/node-cache' 8 | - uses: './.github/actions/cypress-cache' 9 | - uses: './.github/actions/webpack-cache' 10 | - name: Install deps 11 | shell: bash 12 | run: npm i 13 | - name: Test affected 14 | shell: bash 15 | run: npx nx affected -t component-test --configuration=ci 16 | 17 | -------------------------------------------------------------------------------- /examples/test-app/src/routes/UseModuleLoading.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Grid } from '@mui/material'; 3 | import { useModule } from '@scalprum/react-core'; 4 | 5 | const UseModuleLoading = () => { 6 | const Component = useModule('sdk-plugin', './SDKComponent'); 7 | 8 | return ( 9 | 10 | 11 | {Component ? : 'Loading...'} 12 | 13 | 14 | ); 15 | }; 16 | 17 | export default UseModuleLoading; 18 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@scalprum/core", 3 | "version": "0.9.0", 4 | "description": "Includes core functions for scalprum scaffolding.", 5 | "main": "./index.js", 6 | "module": "./esm/index.js", 7 | "typings": "./index.d.ts", 8 | "repository": "https://github.com/scalprum/scaffloding.git", 9 | "author": "Martin Marosi ", 10 | "license": "Apache-2.0", 11 | "scripts": {}, 12 | "dependencies": { 13 | "@openshift/dynamic-plugin-sdk": "^5.0.1", 14 | "tslib": "^2.6.2" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/react-core/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"], 7 | "noPropertyAccessFromIndexSignature": false, 8 | "composite": true, 9 | "declaration": true, 10 | }, 11 | "include": [ 12 | "jest.config.ts", 13 | "src/**/*.test.ts", 14 | "src/**/*.test.tsx", 15 | "src/**/*.spec.ts", 16 | "src/**/*.spec.tsx", 17 | "src/**/*.d.ts", 18 | "src/**/*.ts", 19 | "src/**/*.tsx", 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /examples/test-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "../../", 4 | "jsx": "react-jsx", 5 | "allowJs": false, 6 | "esModuleInterop": false, 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true, 9 | "jsxImportSource": "@emotion/react", 10 | "module": "esnext", 11 | }, 12 | "files": [], 13 | "include": [], 14 | "references": [ 15 | { 16 | "path": "./tsconfig.app.json" 17 | }, 18 | { 19 | "path": "./tsconfig.spec.json" 20 | } 21 | ], 22 | "extends": "../../tsconfig.base.json" 23 | } 24 | -------------------------------------------------------------------------------- /packages/react-core/src/prefetch-context.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createContext } from 'react'; 3 | 4 | export interface PrefetchState { 5 | data: any; 6 | ready: boolean; 7 | error?: string; 8 | } 9 | 10 | export const PrefetchContext = createContext | undefined>(undefined); 11 | 12 | export const PrefetchProvider: React.FC | undefined }>> = ({ children, prefetchPromise }) => { 13 | return {children}; 14 | }; 15 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "forceConsistentCasingInFileNames": true, 6 | "strict": true, 7 | "noImplicitOverride": true, 8 | "noPropertyAccessFromIndexSignature": false, 9 | "noImplicitReturns": true, 10 | "noFallthroughCasesInSwitch": true 11 | }, 12 | "files": [], 13 | "include": [], 14 | "references": [ 15 | { 16 | "path": "./tsconfig.esm.json" 17 | }, 18 | { 19 | "path": "./tsconfig.spec.json" 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /packages/react-core/src/scalprum-context.ts: -------------------------------------------------------------------------------- 1 | import { PluginStore } from '@openshift/dynamic-plugin-sdk'; 2 | import { AppsConfig } from '@scalprum/core'; 3 | import { createContext } from 'react'; 4 | 5 | export interface ScalprumState = Record> { 6 | initialized: boolean; 7 | config: AppsConfig; 8 | api?: T; 9 | pluginStore: PluginStore; 10 | } 11 | 12 | export const ScalprumContext = createContext({ 13 | initialized: false, 14 | config: {}, 15 | api: {}, 16 | pluginStore: {} as unknown as PluginStore, 17 | }); 18 | -------------------------------------------------------------------------------- /packages/react-core/jest.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export default { 3 | displayName: '@scalprum/react-core', 4 | preset: '../../jest.preset.js', 5 | transform: { 6 | '^.+\\.[tj]sx?$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], 7 | }, 8 | moduleNameMapper: { 9 | '^@scalprum/core$': '/../core/src/index.ts', 10 | }, 11 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'html'], 12 | coverageDirectory: '../../coverage/packages/react-core', 13 | testEnvironment: 'jsdom', 14 | testMatch: ['**/*.test.ts?(x)', '**/*.spec.ts?(x)'], 15 | }; 16 | -------------------------------------------------------------------------------- /packages/react-test-utils/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | }, 17 | { 18 | "files": ["*.json"], 19 | "parser": "jsonc-eslint-parser", 20 | "rules": { 21 | "@nx/dependency-checks": "off" 22 | } 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /examples/test-app-e2e/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["plugin:cypress/recommended", "../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": { 8 | "cypress/no-unnecessary-waiting": "off" 9 | } 10 | }, 11 | { 12 | "files": ["*.ts", "*.tsx"], 13 | "rules": {} 14 | }, 15 | { 16 | "files": ["*.js", "*.jsx"], 17 | "rules": {} 18 | }, 19 | { 20 | "files": ["*.cy.{ts,js,tsx,jsx}", "src/**/*.{ts,js,tsx,jsx}"], 21 | "rules": {} 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /packages/react-core/src/ensureImmutability.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Ensures immutability for non-primitive values by creating shallow copies. 3 | * Primitives are returned as-is since they are immutable by nature. 4 | * 5 | * @param value - The value to ensure immutability for 6 | * @returns A shallow copy for objects/arrays, or the original value for primitives 7 | */ 8 | export function ensureImmutability(value: T): T { 9 | if (typeof value === 'object' && value !== null) { 10 | if (Array.isArray(value)) { 11 | return [...value] as T; 12 | } 13 | return { ...value } as T; 14 | } 15 | 16 | return value; 17 | } 18 | -------------------------------------------------------------------------------- /packages/react-core/tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "declaration": true, 6 | "target": "ES5", 7 | "paths": { 8 | // need to link local packages to be able to import them from different than expected dist directory 9 | "@scalprum/core": ["dist/packages/core"], 10 | }, 11 | "rootDir": "./src", 12 | }, 13 | "include": ["src/**/*.ts", "src/**/*.tsx"], 14 | "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.spec.tsx", "src/**/*.cy.ts", "src/**/*.cy.tsx"] 15 | } 16 | -------------------------------------------------------------------------------- /examples/test-app-e2e/src/e2e/test-app/scalprum-api.cy.ts: -------------------------------------------------------------------------------- 1 | describe('Scalprum API', () => { 2 | beforeEach(() => { 3 | cy.handleMetaError(); 4 | }); 5 | 6 | it('should display values from scalprum API', () => { 7 | cy.visit('http://localhost:4200/api'); 8 | cy.contains('API consumer isBeta: false').should('exist'); 9 | }); 10 | 11 | it('should update isBeta value', () => { 12 | cy.visit('http://localhost:4200/api'); 13 | cy.contains('API consumer isBeta: false').should('exist'); 14 | cy.contains('Toggle isBeta').click(); 15 | cy.contains('API consumer isBeta: true').should('exist'); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /examples/test-app/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": [ 7 | "jest", 8 | "node", 9 | "@nx/react/typings/cssmodule.d.ts", 10 | "@nx/react/typings/image.d.ts" 11 | ] 12 | }, 13 | "include": [ 14 | "jest.config.ts", 15 | "src/**/*.test.ts", 16 | "src/**/*.spec.ts", 17 | "src/**/*.test.tsx", 18 | "src/**/*.spec.tsx", 19 | "src/**/*.test.js", 20 | "src/**/*.spec.js", 21 | "src/**/*.test.jsx", 22 | "src/**/*.spec.jsx", 23 | "src/**/*.d.ts" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /examples/test-app/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "types": [ 6 | "node", 7 | 8 | "@nx/react/typings/cssmodule.d.ts", 9 | "@nx/react/typings/image.d.ts" 10 | ] 11 | }, 12 | "exclude": [ 13 | "jest.config.ts", 14 | "src/**/*.spec.ts", 15 | "src/**/*.test.ts", 16 | "src/**/*.spec.tsx", 17 | "src/**/*.test.tsx", 18 | "src/**/*.spec.js", 19 | "src/**/*.test.js", 20 | "src/**/*.spec.jsx", 21 | "src/**/*.test.jsx" 22 | ], 23 | "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"] 24 | } 25 | -------------------------------------------------------------------------------- /packages/react-core/src/useGetState.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useReducer } from 'react'; 2 | import { createSharedStore } from '@scalprum/core'; 3 | import { ensureImmutability } from './ensureImmutability'; 4 | 5 | export function useGetState(store: ReturnType>) { 6 | const [value, dispatch] = useReducer(() => { 7 | const v = store.getState(); 8 | return ensureImmutability(v); 9 | }, store.getState()); 10 | 11 | useEffect(() => { 12 | const unsubscribe = store.subscribeAll(dispatch); 13 | return () => { 14 | unsubscribe(); 15 | }; 16 | }, [store.subscribeAll]); 17 | 18 | return value; 19 | } 20 | -------------------------------------------------------------------------------- /packages/react-test-utils/tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "declaration": true, 6 | "target": "ES5", 7 | "paths": { 8 | // need to link local packages to be able to import them from different than expected dist directory 9 | "@scalprum/core": ["dist/packages/core"], 10 | "@scalprum/react-core": ["dist/packages/react-core"], 11 | }, 12 | "rootDir": "./src", 13 | }, 14 | "include": ["src/**/*.ts", "src/**/*.tsx"], 15 | "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.spec.tsx"] 16 | } 17 | -------------------------------------------------------------------------------- /examples/test-app/src/routes/RootRoute.tsx: -------------------------------------------------------------------------------- 1 | import { Typography } from '@mui/material'; 2 | import { Container } from '@mui/system'; 3 | import React from 'react'; 4 | 5 | const RootRoute = () => { 6 | return ( 7 | 8 | 9 | Scalprum testing page 10 | 11 | 12 | Select pages for different test cases. 13 | 14 | 15 | ); 16 | }; 17 | 18 | export default RootRoute; 19 | -------------------------------------------------------------------------------- /examples/test-app-e2e/src/support/e2e.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.ts using ES2015 syntax: 17 | import './commands'; 18 | -------------------------------------------------------------------------------- /packages/react-core/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './scalprum-component'; 2 | export * from './scalprum-provider'; 3 | export * from './use-scalprum'; 4 | export * from './scalprum-context'; 5 | export * from './use-module'; 6 | export * from './use-load-module'; 7 | export * from './use-prefetch'; 8 | export * from './prefetch-context'; 9 | export * from './use-remote-hook'; 10 | export * from './remote-hooks-types'; 11 | export * from './remote-hook-provider'; 12 | export * from './use-remote-hook-manager'; 13 | export * from './useGetState'; 14 | export * from './useSubscribeStore'; 15 | export * from './ensureImmutability'; 16 | 17 | export { ScalprumProvider as default } from './scalprum-provider'; 18 | -------------------------------------------------------------------------------- /packages/react-core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "module": "ES2015", 5 | "forceConsistentCasingInFileNames": true, 6 | "strict": true, 7 | "noImplicitOverride": false, 8 | "noPropertyAccessFromIndexSignature": true, 9 | "noImplicitReturns": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "jsx": "react", 12 | "esModuleInterop": true, 13 | "rootDir": "." 14 | }, 15 | "files": [], 16 | "include": ["src/**/*.ts", "src/**/*.tsx"], 17 | "references": [ 18 | { 19 | "path": "./tsconfig.spec.json" 20 | }, 21 | { 22 | "path": "./cypress/tsconfig.json" 23 | } 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | dist 5 | tmp 6 | /out-tsc 7 | 8 | # dependencies 9 | node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | .c9/ 16 | *.launch 17 | .settings/ 18 | *.sublime-workspace 19 | 20 | # IDE - VSCode 21 | .vscode/* 22 | 23 | # misc 24 | /.sass-cache 25 | /connect.lock 26 | /coverage 27 | /libpeerconnection.log 28 | npm-debug.log 29 | yarn-error.log 30 | testem.log 31 | /typings 32 | 33 | # System Files 34 | .DS_Store 35 | Thumbs.db 36 | 37 | .nx 38 | .webpack-cache 39 | .vscode 40 | .cdn-cache 41 | .cursor/rules/nx-rules.mdc 42 | .github/instructions/nx.instructions.md 43 | -------------------------------------------------------------------------------- /examples/test-app/src/app/app.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | 3 | import { BrowserRouter } from 'react-router-dom'; 4 | 5 | import App from './app'; 6 | 7 | describe('App', () => { 8 | it('should render successfully', () => { 9 | const { baseElement } = render( 10 | 11 | 12 | , 13 | ); 14 | expect(baseElement).toBeTruthy(); 15 | }); 16 | 17 | it('should have a greeting as the title', () => { 18 | const { getByText } = render( 19 | 20 | 21 | , 22 | ); 23 | expect(getByText(/There will be dragons/gi)).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /examples/test-app/src/routes/ApiUpdates.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Typography } from '@mui/material'; 2 | import { ScalprumComponent } from '@scalprum/react-core'; 3 | 4 | const ApiUpdates = () => { 5 | return ( 6 | 7 | API Updates 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | 20 | export default ApiUpdates; 21 | -------------------------------------------------------------------------------- /packages/react-core/src/use-module.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useCallback } from 'react'; 2 | import { getModule } from '@scalprum/core'; 3 | 4 | export function useModule(scope: string, module: string, defaultState?: any, importName = 'default'): T { 5 | const [data, setData] = useState(defaultState); 6 | const fetchModule = useCallback(async () => { 7 | try { 8 | const Module = await getModule(scope, module, importName); 9 | setData(() => Module); 10 | } catch (error) { 11 | console.error(error); 12 | } 13 | }, [scope, module]); 14 | 15 | useEffect(() => { 16 | fetchModule(); 17 | }, [scope, module, importName]); 18 | 19 | return data; 20 | } 21 | -------------------------------------------------------------------------------- /.verdaccio/config.yml: -------------------------------------------------------------------------------- 1 | # path to a directory with all packages 2 | storage: ../tmp/local-registry/storage 3 | 4 | # a list of other known repositories we can talk to 5 | uplinks: 6 | npmjs: 7 | url: https://registry.npmjs.org/ 8 | maxage: 60m 9 | 10 | packages: 11 | '**': 12 | # give all users (including non-authenticated users) full access 13 | # because it is a local registry 14 | access: $all 15 | publish: $all 16 | unpublish: $all 17 | 18 | # if package is not available locally, proxy requests to npm registry 19 | proxy: npmjs 20 | 21 | # log settings 22 | logs: 23 | type: stdout 24 | format: pretty 25 | level: warn 26 | 27 | publish: 28 | allow_offline: true # set offline to true to allow publish offline 29 | -------------------------------------------------------------------------------- /packages/react-test-utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@scalprum/react-test-utils", 3 | "version": "0.2.11", 4 | "description": "Includes test utilities for scalprum scaffolding.", 5 | "main": "./index.js", 6 | "module": "./esm/index.js", 7 | "typings": "./index.d.ts", 8 | "repository": "https://github.com/scalprum/scaffloding.git", 9 | "author": "Martin Marosi ", 10 | "license": "Apache-2.0", 11 | "scripts": {}, 12 | "dependencies": { 13 | "@openshift/dynamic-plugin-sdk": "^5.0.1", 14 | "@scalprum/core": "^0.9.0", 15 | "@scalprum/react-core": "^0.11.1", 16 | "tslib": "^2.6.2", 17 | "whatwg-fetch": "^3.6.0" 18 | }, 19 | "devDependencies": { 20 | "react": "^17.0.1 || ^18.3.1" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.github/actions/test-e2e/action.yaml: -------------------------------------------------------------------------------- 1 | name: E2E tests 2 | description: verify e2e tests 3 | runs: 4 | using: "composite" 5 | steps: 6 | - uses: nrwl/nx-set-shas@v4 7 | - uses: './.github/actions/node-cache' 8 | - uses: './.github/actions/cypress-cache' 9 | - uses: './.github/actions/webpack-cache' 10 | - name: Install deps 11 | shell: bash 12 | run: npm i 13 | - name: Build packages 14 | shell: bash 15 | run: npm run build 16 | - name: Run e2e tests 17 | shell: bash 18 | run: npm run test:e2e 19 | - name: Upload test results 20 | uses: actions/upload-artifact@v4 21 | if: failure() 22 | with: 23 | name: test-results 24 | path: dist/cypress/examples/test-app-e2e/screenshots/ 25 | 26 | -------------------------------------------------------------------------------- /packages/react-core/src/useSubscribeStore.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useReducer } from 'react'; 2 | import { createSharedStore } from '@scalprum/core'; 3 | import { ensureImmutability } from './ensureImmutability'; 4 | 5 | export function useSubscribeStore( 6 | store: ReturnType>, 7 | event: E[number], 8 | selector: (state: S) => T, 9 | ): T { 10 | const [value, dispatch] = useReducer(() => { 11 | const v = selector(store.getState()); 12 | return ensureImmutability(v); 13 | }, selector(store.getState())); 14 | 15 | useEffect(() => { 16 | const unsubscribe = store.subscribe(event, dispatch); 17 | return () => { 18 | unsubscribe(); 19 | }; 20 | }, [store.subscribe]); 21 | 22 | return value; 23 | } 24 | -------------------------------------------------------------------------------- /packages/build-utils/src/executors/builder/executor.spec.ts: -------------------------------------------------------------------------------- 1 | import { BuilderExecutorSchema } from './schema'; 2 | import executor from './executor'; 3 | import { ExecutorContext } from '@nx/devkit'; 4 | 5 | jest.mock('fs', () => ({ 6 | __esModule: true, 7 | stat: (_path, cb) => cb(), 8 | })); 9 | 10 | jest.mock('child_process', () => ({ 11 | __esModule: true, 12 | exec: (_command, cb) => cb(), 13 | execSync: () => undefined, 14 | })); 15 | 16 | const options: BuilderExecutorSchema = { 17 | assets: [], 18 | cjsTsConfig: 'cjsTsConfig', 19 | esmTsConfig: 'esmTsConfig', 20 | outputPath: 'outputPath', 21 | }; 22 | 23 | describe('Builder Executor', () => { 24 | it('can run', async () => { 25 | const output = await executor(options, {} as ExecutorContext); 26 | expect(output.success).toBe(true); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "sourceMap": true, 5 | "declaration": false, 6 | "moduleResolution": "node", 7 | "emitDecoratorMetadata": true, 8 | "experimentalDecorators": true, 9 | "importHelpers": true, 10 | "target": "es2015", 11 | "module": "esnext", 12 | "lib": ["es2020", "dom"], 13 | "skipLibCheck": true, 14 | "skipDefaultLibCheck": true, 15 | "baseUrl": ".", 16 | "paths": { 17 | "@scalprum/build-utils": ["packages/build-utils/src/index.ts"], 18 | "@scalprum/core": ["packages/core/src/index.ts"], 19 | "@scalprum/react-core": ["packages/react-core/src/index.ts"], 20 | "@scalprum/react-test-utils": ["packages/react-test-utils/src/index.ts"] 21 | } 22 | }, 23 | "exclude": ["node_modules", "tmp"] 24 | } 25 | -------------------------------------------------------------------------------- /packages/react-core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@scalprum/react-core", 3 | "version": "0.11.1", 4 | "description": "React binding for @scalprum/core package.", 5 | "main": "./index.js", 6 | "module": "./esm/index.js", 7 | "typings": "./index.d.ts", 8 | "repository": "https://github.com/scalprum/scaffloding.git", 9 | "author": "Martin Marosi ", 10 | "license": "Apache-2.0", 11 | "scripts": {}, 12 | "devDependencies": { 13 | "@types/react": "^18.3.0", 14 | "@types/react-dom": "^18.3.0" 15 | }, 16 | "dependencies": { 17 | "@openshift/dynamic-plugin-sdk": "^5.0.1", 18 | "@scalprum/core": "^0.9.0", 19 | "lodash": "^4.17.0" 20 | }, 21 | "peerDependencies": { 22 | "react": ">=16.8.0 || >=17.0.0 || ^18.0.0", 23 | "react-dom": ">=16.8.0 || >=17.0.0 || ^18.0.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/react-core/src/async-loader.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ExposedScalprumModule, getCachedModule, getScalprum, PrefetchFunction } from '@scalprum/core'; 3 | 4 | export async function loadComponent

( 5 | scope: string, 6 | module: string, 7 | importName = 'default', 8 | ): Promise<{ prefetch?: PrefetchFunction; component: React.ComponentType

}> { 9 | { 10 | const { pluginStore } = getScalprum(); 11 | let mod: ExposedScalprumModule | undefined; 12 | const { cachedModule } = getCachedModule, PrefetchFunction>(scope, module); 13 | mod = cachedModule; 14 | if (!mod) { 15 | mod = await pluginStore.getExposedModule(scope, module); 16 | } 17 | return { 18 | prefetch: mod.prefetch, 19 | component: mod[importName], 20 | }; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/core/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": { 8 | "@typescript-eslint/no-explicit-any": "warn", 9 | "@typescript-eslint/ban-types": [ 10 | "error", 11 | { 12 | "types": { 13 | "{}": false 14 | }, 15 | "extendDefaults": true 16 | } 17 | ]} 18 | }, 19 | { 20 | "files": ["*.ts", "*.tsx"], 21 | "rules": {} 22 | }, 23 | { 24 | "files": ["*.js", "*.jsx"], 25 | "rules": {} 26 | }, 27 | { 28 | "files": ["*.json"], 29 | "parser": "jsonc-eslint-parser", 30 | "rules": { 31 | "@nx/dependency-checks": "off" 32 | } 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /examples/test-app-e2e/cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset'; 2 | import { defineConfig } from 'cypress'; 3 | 4 | const nxe2eConfig = nxE2EPreset(__filename, { cypressDir: 'src' }); 5 | 6 | // Adds logging to terminal for debugging 7 | // https://docs.cypress.io/api/commands/task#Usage 8 | // Usage: `cy.task('log', 'my message'); 9 | export default defineConfig({ 10 | e2e: { 11 | ...nxe2eConfig, 12 | setupNodeEvents(on) { 13 | on('task', { 14 | log(message) { 15 | console.log(message); 16 | return null; 17 | }, 18 | }); 19 | }, 20 | // Please ensure you use `cy.origin()` when navigating between domains and remove this option. 21 | // See https://docs.cypress.io/app/references/migration-guide#Changes-to-cyorigin 22 | injectDocumentDomain: true, 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /packages/react-core/src/use-prefetch.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useContext, useState } from 'react'; 3 | import { PrefetchState, PrefetchContext } from './prefetch-context'; 4 | 5 | const defaultState = { 6 | data: undefined, 7 | ready: false, 8 | error: undefined, 9 | }; 10 | 11 | export const usePrefetch = (): PrefetchState => { 12 | const [currState, setCurrState] = useState(defaultState); 13 | const promise = useContext(PrefetchContext); 14 | 15 | useEffect(() => { 16 | currState.ready = false; 17 | promise 18 | ?.then((res) => { 19 | setCurrState({ ...currState, error: undefined, data: res, ready: true }); 20 | }) 21 | .catch((e) => { 22 | setCurrState({ ...currState, ready: true, data: undefined, error: e }); 23 | }); 24 | }, [promise]); 25 | return currState; 26 | }; 27 | -------------------------------------------------------------------------------- /packages/core/src/warnDuplicatePkg.ts: -------------------------------------------------------------------------------- 1 | interface Package { 2 | from: string; 3 | eager?: boolean; 4 | loaded?: number; 5 | } 6 | 7 | interface Packages { 8 | [key: string]: { 9 | [key: string]: Package; 10 | }; 11 | } 12 | 13 | /** 14 | * Warns applications using the shared scope if they have packages multiple times 15 | */ 16 | export const warnDuplicatePkg = (packages: Packages) => { 17 | const entries = Object.entries(packages); 18 | 19 | entries.forEach(([pkgName, versions]) => { 20 | const instances = Object.keys(versions); 21 | if (instances.length > 1) { 22 | console.warn( 23 | `[SCALPRUM]: You have ${pkgName} package that is being loaded into browser multiple times. You might want to align your version with the chrome one.`, 24 | ); 25 | console.warn(`[SCALPRUM]: All packages instances:`, versions); 26 | } 27 | }); 28 | }; 29 | -------------------------------------------------------------------------------- /packages/react-core/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": { 8 | "@typescript-eslint/no-explicit-any": "warn", 9 | "@typescript-eslint/ban-types": [ 10 | "error", 11 | { 12 | "types": { 13 | "{}": false 14 | }, 15 | "extendDefaults": true 16 | } 17 | ] 18 | } 19 | }, 20 | { 21 | "files": ["*.ts", "*.tsx"], 22 | "rules": {} 23 | }, 24 | { 25 | "files": ["*.js", "*.jsx"], 26 | "rules": {} 27 | }, 28 | { 29 | "files": ["*.json"], 30 | "parser": "jsonc-eslint-parser", 31 | "rules": { 32 | "@nx/dependency-checks": "off" 33 | } 34 | } 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /packages/react-test-utils/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "compilerOptions": { 4 | "module": "ES2015", 5 | "forceConsistentCasingInFileNames": true, 6 | "strict": true, 7 | "noImplicitOverride": false, 8 | "noPropertyAccessFromIndexSignature": true, 9 | "noImplicitReturns": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "jsx": "react", 12 | "esModuleInterop": true, 13 | "paths": { 14 | // need to link local packages to be able to import them from different than expected dist directory 15 | "@scalprum/core": ["dist/packages/core"], 16 | "@scalprum/react-core": ["dist/packages/react-core"], 17 | }, 18 | "rootDir": ".", 19 | }, 20 | "files": [], 21 | "include": ["src/**/*.ts", "src/**/*.tsx"], 22 | "references": [ 23 | { 24 | "path": "./tsconfig.spec.json" 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /federation-cdn-mock/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "federation-cdn-mock", 3 | "private": true, 4 | "scripts": { 5 | "build": "webpack", 6 | "watch": "webpack --watch", 7 | "serve": "wait-on dist && http-server dist -p 8001 -c-1 --cors=*" 8 | }, 9 | "devDependencies": { 10 | "@module-federation/enhanced": "^0.10.0", 11 | "http-server": "^14.1.1", 12 | "swc-loader": "^0.2.6", 13 | "wait-on": "^7.2.0", 14 | "webpack-cli": "^5.1.4" 15 | }, 16 | "dependencies": { 17 | "@emotion/react": "^11.11.4", 18 | "@emotion/styled": "^11.11.5", 19 | "@mui/icons-material": "^5.15.15", 20 | "@mui/material": "^5.15.15", 21 | "@mui/styled-engine": "^5.15.14", 22 | "@scalprum/core": "file:../dist/packages/core", 23 | "@scalprum/react-core": "file:../dist/packages/react-core", 24 | "react": "^18.2.0", 25 | "react-dom": "^18.2.0", 26 | "tslib": "^2.6.2" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /dev-script.js: -------------------------------------------------------------------------------- 1 | const concurrently = require('concurrently') 2 | const path = require('path') 3 | const fs = require('fs') 4 | const { execSync } = require('child_process') 5 | 6 | const cdnPath = path.resolve(__dirname, './federation-cdn-mock') 7 | const cdnAssetsPath = path.resolve(__dirname, './federation-cdn-mock/dist') 8 | 9 | try { 10 | fs.statSync(cdnAssetsPath) 11 | } catch (error) { 12 | // create server asset dir 13 | fs.mkdirSync(cdnAssetsPath) 14 | } 15 | 16 | // ensure the deps exist before we start the servers 17 | execSync('npm run build', { cwd: cdnPath, stdio: 'inherit'}) 18 | 19 | const { commands } = concurrently( 20 | [{ 21 | cwd: cdnPath, 22 | command: 'npm run watch', 23 | }, { 24 | cwd: cdnPath, 25 | command: 'npm run serve', 26 | }, 27 | { 28 | cwd: __dirname, 29 | command: 'npx nx run test-app:serve', 30 | }] 31 | ) 32 | 33 | 34 | // cleanup dev servers 35 | process.on('SIGINT', () => { 36 | commands.forEach((c) => { 37 | c.close() 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /federation-cdn-mock/src/modules/useCounterHook.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | export interface UseCounterOptions { 4 | initialValue?: number; 5 | step?: number; 6 | } 7 | 8 | export interface UseCounterResult { 9 | count: number; 10 | increment: () => void; 11 | decrement: () => void; 12 | reset: () => void; 13 | setCount: (value: number) => void; 14 | } 15 | 16 | /** 17 | * Remote hook that provides counter functionality 18 | * This will be loaded and executed remotely via useRemoteHook 19 | */ 20 | export const useCounterHook = (options: UseCounterOptions = {}): UseCounterResult => { 21 | const { 22 | initialValue = 0, 23 | step = 1, 24 | } = options; 25 | 26 | const [count, setCount] = useState(initialValue); 27 | 28 | const increment = () => setCount(prev => prev + step); 29 | const decrement = () => setCount(prev => prev - step); 30 | const reset = () => setCount(initialValue); 31 | 32 | return { 33 | count, 34 | increment, 35 | decrement, 36 | reset, 37 | setCount, 38 | }; 39 | }; 40 | 41 | export default useCounterHook; -------------------------------------------------------------------------------- /examples/test-app-e2e/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-app-e2e", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "projectType": "application", 5 | "sourceRoot": "examples/test-app-e2e/src", 6 | "targets": { 7 | "e2e": { 8 | "executor": "@nx/cypress:cypress", 9 | "options": { 10 | "skipServe": true, 11 | "cypressConfig": "examples/test-app-e2e/cypress.config.ts", 12 | "testingType": "e2e", 13 | "devServerTarget": "test-app:serve", 14 | "baseUrl": "http://localhost:4200" 15 | }, 16 | "configurations": { 17 | "production": { 18 | "devServerTarget": "test-app:serve:production" 19 | }, 20 | "ci": { 21 | "devServerTarget": "test-app:serve-static" 22 | } 23 | } 24 | }, 25 | "lint": { 26 | "executor": "@nx/eslint:lint", 27 | "outputs": ["{options.outputFile}"], 28 | "options": { 29 | "lintFilePatterns": ["examples/test-app-e2e/**/*.{js,ts}"] 30 | } 31 | } 32 | }, 33 | "implicitDependencies": ["test-app"], 34 | "tags": [] 35 | } 36 | -------------------------------------------------------------------------------- /packages/react-core/cypress/support/component.ts: -------------------------------------------------------------------------------- 1 | import { mount } from 'cypress/react'; 2 | // *********************************************************** 3 | // This example support/component.ts is processed and 4 | // loaded automatically before your test files. 5 | // 6 | // This is a great place to put global configuration and 7 | // behavior that modifies Cypress. 8 | // 9 | // You can change the location of this file or turn off 10 | // automatically serving support files with the 11 | // 'supportFile' configuration option. 12 | // 13 | // You can read more here: 14 | // https://on.cypress.io/configuration 15 | // *********************************************************** 16 | 17 | // Import commands.ts using ES2015 syntax: 18 | import './commands'; 19 | 20 | // add component testing only related command here, such as mount 21 | declare global { 22 | // eslint-disable-next-line @typescript-eslint/no-namespace 23 | namespace Cypress { 24 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 25 | interface Chainable { 26 | mount: typeof mount; 27 | } 28 | } 29 | } 30 | 31 | Cypress.Commands.add('mount', mount); 32 | -------------------------------------------------------------------------------- /examples/test-app/src/app/app.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import React, { useEffect, useState } from 'react'; 3 | 4 | const RemoteSetup = () => { 5 | const [Component, setComponent] = useState(undefined); 6 | useEffect(() => { 7 | const src = '/testApp.js'; 8 | const script = document.createElement('script'); 9 | script.src = src; 10 | script.onload = async () => { 11 | document.body.removeChild(script); 12 | // @ts-ignore 13 | await global['testApp'].init(__webpack_share_scopes__.default); 14 | // @ts-ignore 15 | const mod = await global['testApp'].get('BaseModule'); 16 | console.log({ mod: mod().default }); 17 | setComponent(() => mod().default); 18 | }; 19 | document.body.appendChild(script); 20 | }, []); 21 | return

{!Component ?

Loading...

: }
; 22 | }; 23 | 24 | export function App() { 25 | return ( 26 | 27 |
28 |

There will be dragons

29 |
30 | 31 |
32 | ); 33 | } 34 | 35 | export default App; 36 | -------------------------------------------------------------------------------- /examples/test-app-e2e/src/e2e/test-app/sdk-plugin-loading.cy.ts: -------------------------------------------------------------------------------- 1 | describe('SDK module loading', () => { 2 | beforeEach(() => { 3 | cy.handleMetaError(); 4 | }); 5 | it('should show data from prefetch', () => { 6 | cy.visit('http://localhost:4200/sdk'); 7 | cy.get('div#sdk-module-item').contains('SDK Inbox').should('exist'); 8 | }); 9 | 10 | it('should render a slider from the pluginManifest', () => { 11 | cy.intercept('GET', '/full-manifest.js?cacheBuster=*').as('manifestRequest'); 12 | cy.visit('http://localhost:4200/sdk'); 13 | 14 | cy.wait('@manifestRequest').then((interception) => { 15 | expect(interception.response.statusCode).to.eq(200); 16 | expect(interception.response.headers['content-type']).to.include('application/javascript'); 17 | }); 18 | cy.get(`[aria-label="Checked"]`).should('exist'); 19 | cy.get('#plugin-manifest').should('exist'); 20 | }); 21 | 22 | it('should render delayed module without processing entire manifest', () => { 23 | cy.visit('http://localhost:4200/sdk'); 24 | // Delayed module is fetched after 5 seconds 25 | cy.wait(5001); 26 | cy.get('#delayed-module').should('exist'); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /packages/build-utils/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | }, 17 | { 18 | "files": ["*.json"], 19 | "parser": "jsonc-eslint-parser", 20 | "rules": { 21 | "@nx/dependency-checks": "error" 22 | } 23 | }, 24 | { 25 | "files": ["./package.json", "./executors.json"], 26 | "parser": "jsonc-eslint-parser", 27 | "rules": { 28 | "@nx/nx-plugin-checks": "error", 29 | "@nx/dependency-checks": [ 30 | "error", 31 | { 32 | "buildTargets": ["build"], 33 | "checkMissingDependencies": true, 34 | "checkObsoleteDependencies": true, 35 | "checkVersionMismatches": true, 36 | "ignoredDependencies": [ 37 | "semver", 38 | "zod", 39 | "@nx/devkit" 40 | ] 41 | } 42 | ] 43 | } 44 | } 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /packages/react-core/src/default-error-component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | type ErrorWithCause = { 4 | cause: { 5 | message?: string; 6 | name?: string; 7 | request?: string; 8 | type?: string; 9 | stack?: string; 10 | }; 11 | }; 12 | function isErrorWithCause(error: any): error is ErrorWithCause { 13 | return error?.cause && typeof error.cause === 'object'; 14 | } 15 | 16 | const DefaultErrorComponent = ({ 17 | error, 18 | errorInfo, 19 | }: { 20 | error?: { 21 | cause?: ErrorWithCause; 22 | message?: React.ReactNode; 23 | stack?: React.ReactNode; 24 | }; 25 | errorInfo?: { 26 | componentStack?: React.ReactNode; 27 | }; 28 | }) => { 29 | if (isErrorWithCause(error)) { 30 | return ; 31 | } 32 | return ( 33 |
34 |

Error loading component

35 | {typeof error === 'string' &&

{error}

} 36 | {error?.cause && typeof error?.cause !== 'object' &&

{error.cause}

} 37 | {error?.message &&

{error.message}

} 38 | {errorInfo?.componentStack ?
{errorInfo?.componentStack}
: error?.stack &&
{error.stack}
} 39 |
40 | ); 41 | }; 42 | 43 | export default DefaultErrorComponent; 44 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": ["**/*"], 4 | "plugins": ["@nx", "prettier"], 5 | "extends": [ 6 | "plugin:prettier/recommended" 7 | ], 8 | "overrides": [ 9 | { 10 | "files": "*.json", 11 | "parser": "jsonc-eslint-parser", 12 | "rules": {} 13 | }, 14 | { 15 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 16 | "rules": { 17 | "@nx/enforce-module-boundaries": [ 18 | "error", 19 | { 20 | "enforceBuildableLibDependency": true, 21 | "allow": [], 22 | "depConstraints": [ 23 | { 24 | "sourceTag": "*", 25 | "onlyDependOnLibsWithTags": ["*"] 26 | } 27 | ] 28 | } 29 | ] 30 | } 31 | }, 32 | { 33 | "files": ["*.ts", "*.tsx"], 34 | "extends": ["plugin:@nx/typescript"], 35 | "rules": {} 36 | }, 37 | { 38 | "files": ["*.js", "*.jsx"], 39 | "extends": ["plugin:@nx/javascript"], 40 | "rules": {} 41 | }, 42 | { 43 | "files": ["*.spec.ts", "*.spec.tsx", "*.spec.js", "*.spec.jsx"], 44 | "env": { 45 | "jest": true 46 | }, 47 | "rules": {} 48 | } 49 | ], 50 | "rules": { 51 | "@nx/dependency-checks": "off" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/react-core/cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | // *********************************************** 4 | // This example commands.ts shows you how to 5 | // create various custom commands and overwrite 6 | // existing commands. 7 | // 8 | // For more comprehensive examples of custom 9 | // commands please read more here: 10 | // https://on.cypress.io/custom-commands 11 | // *********************************************** 12 | 13 | // eslint-disable-next-line @typescript-eslint/no-namespace 14 | declare namespace Cypress { 15 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 16 | interface Chainable { 17 | login(email: string, password: string): void; 18 | } 19 | } 20 | 21 | // -- This is a parent command -- 22 | Cypress.Commands.add('login', (email, password) => { 23 | console.log('Custom command example: Login', email, password); 24 | }); 25 | // 26 | // -- This is a child command -- 27 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 28 | // 29 | // 30 | // -- This is a dual command -- 31 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 32 | // 33 | // 34 | // -- This will overwrite an existing command -- 35 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 36 | -------------------------------------------------------------------------------- /packages/react-test-utils/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@scalprum/react-test-utils", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "sourceRoot": "packages/react-test-utils/src", 5 | "projectType": "library", 6 | "tags": [], 7 | "targets": { 8 | "build": { 9 | "executor": "@scalprum/build-utils:builder", 10 | "options": { 11 | "outputPath": "dist/packages/react-test-utils", 12 | "esmTsConfig": "packages/react-test-utils/tsconfig.esm.json", 13 | "cjsTsConfig": "packages/react-test-utils/tsconfig.cjs.json", 14 | "assets": ["packages/react-test-utils/*.md"] 15 | } 16 | }, 17 | "lint": { 18 | "executor": "@nx/eslint:lint", 19 | "outputs": ["{options.outputFile}"], 20 | "options": { 21 | "lintFilePatterns": ["packages/react-test-utils/**/*.ts", "packages/react-test-utils/package.json"] 22 | } 23 | }, 24 | "test": { 25 | "executor": "@nx/jest:jest", 26 | "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], 27 | "options": { 28 | "jestConfig": "packages/react-test-utils/jest.config.ts" 29 | } 30 | }, 31 | "nx-release-publish": { 32 | "options": { 33 | "packageRoot": "dist/{projectRoot}" 34 | } 35 | }, 36 | "syncDependencies": { 37 | "executor": "@scalprum/build-utils:sync-dependencies" 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /packages/core/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@scalprum/core", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "sourceRoot": "packages/core/src", 5 | "projectType": "library", 6 | "tags": [], 7 | "targets": { 8 | "build": { 9 | "executor": "@scalprum/build-utils:builder", 10 | "options": { 11 | "outputPath": "dist/packages/core", 12 | "esmTsConfig": "packages/core/tsconfig.esm.json", 13 | "cjsTsConfig": "packages/core/tsconfig.cjs.json", 14 | "assets": ["packages/core/*.md"] 15 | } 16 | }, 17 | "publish": { 18 | "command": "node tools/scripts/publish.mjs @scalprum/core {args.ver} {args.tag}", 19 | "dependsOn": ["build"] 20 | }, 21 | "lint": { 22 | "executor": "@nx/eslint:lint", 23 | "outputs": ["{options.outputFile}"], 24 | "options": { 25 | "lintFilePatterns": ["packages/core/**/*.ts", "packages/core/package.json"] 26 | } 27 | }, 28 | "test": { 29 | "executor": "@nx/jest:jest", 30 | "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], 31 | "options": { 32 | "jestConfig": "packages/core/jest.config.ts" 33 | } 34 | }, 35 | "nx-release-publish": { 36 | "options": { 37 | "packageRoot": "dist/{projectRoot}" 38 | } 39 | }, 40 | "syncDependencies": { 41 | "executor": "@scalprum/build-utils:sync-dependencies" 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /examples/test-app-e2e/src/e2e/test-app/module-prefetch-data.cy.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | describe('Data prefetch', () => { 3 | it('should show data from prefetch', () => { 4 | cy.visit('http://localhost:4200/legacy'); 5 | 6 | cy.get('h2').contains('Module one remote component').should('exist'); 7 | cy.get('p#success').contains('Hello').should('exist'); 8 | }); 9 | it('should show error message on prefetch failure', () => { 10 | cy.visit('http://localhost:4200/legacy'); 11 | cy.window().then((win) => { 12 | // @ts-ignore 13 | win.prefetchError = true; 14 | }); 15 | 16 | cy.on('uncaught:exception', () => { 17 | // exceptions are expected during this test 18 | // returning false here prevents Cypress from failing the test 19 | return false; 20 | }); 21 | 22 | cy.get('h2').contains('Module one remote component').should('exist'); 23 | cy.get('p#error').contains('Expected error').should('exist'); 24 | }); 25 | it('should render component when module does not have prefetch', () => { 26 | cy.visit('http://localhost:4200/legacy'); 27 | 28 | cy.get('#render-preload-module').click(); 29 | cy.get('h2#preload-heading').contains('This module is supposed to be pre-loaded').should('exist'); 30 | }); 31 | it('should call prefetch only once', () => { 32 | cy.visit('http://localhost:4200/legacy'); 33 | 34 | cy.get('#render-prefetch-module').click(); 35 | cy.wait(1000); 36 | cy.window().its('prefetchCounter').should('equal', 1); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /packages/react-core/src/remote-hooks-types.ts: -------------------------------------------------------------------------------- 1 | // Shared types for remote hooks functionality 2 | export interface HookConfig { 3 | scope: string; 4 | module: string; 5 | importName?: string; 6 | args?: any[]; 7 | } 8 | 9 | export interface UseRemoteHookResult { 10 | id: string; 11 | loading: boolean; 12 | error: Error | null; 13 | hookResult?: T; 14 | } 15 | 16 | export interface RemoteHookHandle { 17 | readonly loading: boolean; 18 | readonly error: Error | null; 19 | readonly hookResult?: T; 20 | readonly id: string; 21 | 22 | updateArgs(args: any[]): void; 23 | remove(): void; 24 | subscribe(callback: (result: UseRemoteHookResult) => void): () => void; 25 | } 26 | 27 | export interface HookHandle { 28 | remove(): void; 29 | updateArgs(args: any[]): void; 30 | } 31 | 32 | export interface RemoteHookManager { 33 | addHook(config: HookConfig): HookHandle; // Returns handle with remove and updateArgs 34 | cleanup(): void; // Cleanup for component unmount 35 | hookResults: UseRemoteHookResult[]; // Results for all tracked hooks 36 | } 37 | 38 | // Context type from RemoteHookProvider 39 | export interface RemoteHookContextType { 40 | subscribe: (notify: () => void) => { id: string; unsubscribe: () => void }; 41 | updateState: (id: string, value: any) => void; 42 | getState: (id: string) => any; 43 | registerHook: (id: string, hookFunction: (...args: any[]) => any) => void; 44 | updateArgs: (id: string, args: any[]) => void; 45 | subscribeToArgs: (id: string, callback: (args: any[]) => void) => () => void; 46 | } 47 | -------------------------------------------------------------------------------- /federation-cdn-mock/src/modules/delayedModule.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import FormatAlignLeftIcon from '@mui/icons-material/FormatAlignLeft'; 3 | import FormatAlignCenterIcon from '@mui/icons-material/FormatAlignCenter'; 4 | import FormatAlignRightIcon from '@mui/icons-material/FormatAlignRight'; 5 | import FormatAlignJustifyIcon from '@mui/icons-material/FormatAlignJustify'; 6 | import ToggleButton from '@mui/material/ToggleButton'; 7 | import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; 8 | 9 | export default function ToggleButtons() { 10 | const [alignment, setAlignment] = React.useState('left'); 11 | 12 | const handleAlignment = ( 13 | event: React.MouseEvent, 14 | newAlignment: string | null, 15 | ) => { 16 | setAlignment(newAlignment); 17 | }; 18 | 19 | return ( 20 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /e2e-script.js: -------------------------------------------------------------------------------- 1 | const concurrently = require('concurrently') 2 | const path = require('path') 3 | const fs = require('fs') 4 | const { execSync } = require('child_process') 5 | 6 | const cdnPath = path.resolve(__dirname, './federation-cdn-mock') 7 | const cdnAssetsPath = path.resolve(__dirname, './federation-cdn-mock/dist') 8 | 9 | try { 10 | fs.statSync(cdnAssetsPath) 11 | } catch (error) { 12 | // create server asset dir 13 | fs.mkdirSync(cdnAssetsPath) 14 | } 15 | 16 | // install private package 17 | execSync('npm install', { cwd: cdnPath, stdio: 'inherit'}) 18 | // ensure the deps exist before we start the servers 19 | execSync('npm run build -c webpack.config.js', { cwd: cdnPath, stdio: 'inherit'}) 20 | 21 | const {result, commands} = concurrently( 22 | [{ 23 | name: 'cdn-server', 24 | cwd: cdnPath, 25 | command: 'npm run serve', 26 | }, 27 | { 28 | cwd: __dirname, 29 | name: 'test-app', 30 | command: 'npx nx run test-app:serve', 31 | }, { 32 | cwd: __dirname, 33 | name: 'e2e', 34 | command: 'npx wait-on http://localhost:4200 http://127.0.0.1:8001 && npx nx run test-app-e2e:e2e --skipNxCache', 35 | }], 36 | { 37 | successCondition: 'e2e', 38 | killOthers: ['success', 'failure'], 39 | } 40 | ) 41 | 42 | 43 | result.catch((e) => { 44 | const e2eJob = e.find(({ command: {name} }) => name ==='e2e') 45 | if(!e2eJob) { 46 | console.error('E2E tests job not found') 47 | process.exit(1) 48 | } 49 | 50 | if(e2eJob.exitCode === 0) { 51 | process.exit(0) 52 | } else { 53 | console.error('E2E tests failed') 54 | process.exit(1) 55 | } 56 | }) 57 | -------------------------------------------------------------------------------- /examples/test-app-e2e/src/support/commands.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | // *********************************************** 4 | // This example commands.ts shows you how to 5 | // create various custom commands and overwrite 6 | // existing commands. 7 | // 8 | // For more comprehensive examples of custom 9 | // commands please read more here: 10 | // https://on.cypress.io/custom-commands 11 | // *********************************************** 12 | 13 | // eslint-disable-next-line @typescript-eslint/no-namespace 14 | declare namespace Cypress { 15 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 16 | interface Chainable { 17 | login(email: string, password: string): void; 18 | handleMetaError(): void; 19 | } 20 | } 21 | 22 | // -- This is a parent command -- 23 | Cypress.Commands.add('login', (email, password) => { 24 | console.log('Custom command example: Login', email, password); 25 | }); 26 | 27 | Cypress.Commands.add('handleMetaError', () => { 28 | cy.on('uncaught:exception', (err) => { 29 | if (err.message.includes(`Cannot use 'import.meta' outside a module`)) { 30 | return false; 31 | } 32 | 33 | throw err; 34 | }); 35 | }); 36 | // 37 | // -- This is a child command -- 38 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 39 | // 40 | // 41 | // -- This is a dual command -- 42 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 43 | // 44 | // 45 | // -- This will overwrite an existing command -- 46 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 47 | -------------------------------------------------------------------------------- /packages/react-core/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@scalprum/react-core", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "sourceRoot": "packages/react-core/src", 5 | "projectType": "library", 6 | "targets": { 7 | "build": { 8 | "executor": "@scalprum/build-utils:builder", 9 | "options": { 10 | "outputPath": "dist/packages/react-core", 11 | "esmTsConfig": "packages/react-core/tsconfig.esm.json", 12 | "cjsTsConfig": "packages/react-core/tsconfig.cjs.json", 13 | "assets": ["packages/react-core/*.md", "packages/react-core/docs"] 14 | } 15 | }, 16 | "lint": { 17 | "executor": "@nx/eslint:lint", 18 | "outputs": ["{options.outputFile}"], 19 | "options": { 20 | "lintFilePatterns": ["packages/react-core/**/*.ts", "packages/react-core/package.json"] 21 | } 22 | }, 23 | "test": { 24 | "executor": "@nx/jest:jest", 25 | "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], 26 | "options": { 27 | "jestConfig": "packages/react-core/jest.config.ts" 28 | } 29 | }, 30 | "nx-release-publish": { 31 | "options": { 32 | "packageRoot": "dist/{projectRoot}" 33 | } 34 | }, 35 | "syncDependencies": { 36 | "executor": "@scalprum/build-utils:sync-dependencies" 37 | }, 38 | "tags": {}, 39 | "component-test": { 40 | "executor": "@nx/cypress:cypress", 41 | "options": { 42 | "cypressConfig": "packages/react-core/cypress.config.js", 43 | "testingType": "component", 44 | "devServerTarget": "test-app:build", 45 | "skipServe": true 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /examples/test-app/src/routes/SDKModules.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { ScalprumComponent } from '@scalprum/react-core'; 3 | import { Grid, Typography } from '@mui/material'; 4 | 5 | const SDKModules = () => { 6 | const [delayed, setDelayed] = useState(false); 7 | const [seconds, setSeconds] = useState(0); 8 | useEffect(() => { 9 | const timeout = setTimeout(() => { 10 | setDelayed(true); 11 | }, 5000); 12 | const interval = setInterval(() => { 13 | if (seconds >= 6) { 14 | clearInterval(interval); 15 | return; 16 | } 17 | setSeconds((prevSeconds) => prevSeconds + 1); 18 | }, 1000); 19 | return () => { 20 | clearTimeout(timeout); 21 | clearInterval(interval); 22 | }; 23 | }, []); 24 | const props = { 25 | name: 'plugin-manifest', 26 | }; 27 | return ( 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | {delayed ? ( 41 | 42 | ) : ( 43 | Loading delayed module in {5 - seconds} seconds 44 | )} 45 | 46 | 47 | ); 48 | }; 49 | 50 | export default SDKModules; 51 | -------------------------------------------------------------------------------- /packages/react-core/src/__snapshots__/scalprum-component.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` should render component with importName prop from cache 1`] = ` 4 |
5 |
8 | named export Component 9 |
10 |
11 | `; 12 | 13 | exports[` should render error component 1`] = ` 14 |
15 |

16 | Custom error component 17 |

18 |
19 | `; 20 | 21 | exports[` should render error component if self-repair attempt fails 1`] = ` 22 |
23 |

24 | Custom error component 25 |

26 |
27 | `; 28 | 29 | exports[` should render fallback component 1`] = ` 30 |
31 |

32 | Suspense fallback 33 |

34 |
35 | `; 36 | 37 | exports[` should render fallback component 2`] = ` 38 |
39 |

40 | Suspense fallback 41 |

42 |
43 | `; 44 | 45 | exports[` should render test component 1`] = ` 46 |
47 | 48 | Test 49 | 50 |
51 | `; 52 | 53 | exports[` should render test component with manifest 1`] = ` 54 |
55 | 56 | Test 57 | 58 |
59 | `; 60 | 61 | exports[` should retrieve module from scalprum cache 1`] = ` 62 |
63 |
66 | Cached component 67 |
68 |
69 | `; 70 | 71 | exports[` should try and re-render original component on first error 1`] = ` 72 |
73 | 74 | Test 75 | 76 |
77 | `; 78 | -------------------------------------------------------------------------------- /packages/build-utils/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "build-utils", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "sourceRoot": "packages/build-utils/src", 5 | "projectType": "library", 6 | "tags": [], 7 | "targets": { 8 | "build": { 9 | "executor": "@nx/js:tsc", 10 | "outputs": ["{options.outputPath}"], 11 | "options": { 12 | "outputPath": "dist/packages/build-utils", 13 | "main": "packages/build-utils/src/index.ts", 14 | "tsConfig": "packages/build-utils/tsconfig.lib.json", 15 | "assets": [ 16 | "packages/build-utils/*.md", 17 | { 18 | "input": "./packages/build-utils/src", 19 | "glob": "**/!(*.ts)", 20 | "output": "./src" 21 | }, 22 | { 23 | "input": "./packages/build-utils/src", 24 | "glob": "**/*.d.ts", 25 | "output": "./src" 26 | }, 27 | { 28 | "input": "./packages/build-utils", 29 | "glob": "generators.json", 30 | "output": "." 31 | }, 32 | { 33 | "input": "./packages/build-utils", 34 | "glob": "executors.json", 35 | "output": "." 36 | } 37 | ] 38 | } 39 | }, 40 | "lint": { 41 | "executor": "@nx/eslint:lint", 42 | "outputs": ["{options.outputFile}"], 43 | "options": { 44 | "lintFilePatterns": ["packages/build-utils/**/*.ts", "packages/build-utils/package.json", "packages/build-utils/executors.json"] 45 | } 46 | }, 47 | "test": { 48 | "executor": "@nx/jest:jest", 49 | "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], 50 | "options": { 51 | "jestConfig": "packages/build-utils/jest.config.ts" 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /federation-cdn-mock/src/modules/useApiHook.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | export interface UseApiOptions { 4 | url?: string; 5 | delay?: number; 6 | mockData?: any; 7 | shouldFail?: boolean; 8 | } 9 | 10 | export interface UseApiResult { 11 | data: T | null; 12 | loading: boolean; 13 | error: string | null; 14 | refetch: () => void; 15 | } 16 | 17 | /** 18 | * Remote hook that simulates API calls 19 | * This will be loaded and executed remotely via useRemoteHook 20 | */ 21 | export const useApiHook = (options: UseApiOptions = {}): UseApiResult => { 22 | const { 23 | url = '/api/data', 24 | delay = 1000, 25 | mockData = { id: 1, message: 'Hello from remote API hook!' }, 26 | shouldFail = false 27 | } = options; 28 | 29 | const [data, setData] = useState(null); 30 | const [loading, setLoading] = useState(false); 31 | const [error, setError] = useState(null); 32 | 33 | const fetchData = async () => { 34 | setLoading(true); 35 | setError(null); 36 | setData(null); 37 | 38 | try { 39 | // Simulate network delay 40 | await new Promise(resolve => setTimeout(resolve, delay)); 41 | 42 | if (shouldFail) { 43 | throw new Error(`Failed to fetch data from ${url}`); 44 | } 45 | 46 | // Simulate successful API response 47 | setData(mockData); 48 | } catch (err) { 49 | setError(err instanceof Error ? err.message : 'Unknown error occurred'); 50 | } finally { 51 | setLoading(false); 52 | } 53 | }; 54 | 55 | useEffect(() => { 56 | fetchData(); 57 | }, [url, delay, shouldFail]); 58 | 59 | const refetch = () => { 60 | fetchData(); 61 | }; 62 | 63 | return { 64 | data, 65 | loading, 66 | error, 67 | refetch, 68 | }; 69 | }; 70 | 71 | export default useApiHook; -------------------------------------------------------------------------------- /.github/actions/release/action.yaml: -------------------------------------------------------------------------------- 1 | name: Release job 2 | description: build affected packages and publish them to npm 3 | inputs: 4 | npm_token: 5 | description: 'NPM token' 6 | required: true 7 | gh_token: 8 | description: 'Github token' 9 | required: true 10 | gh_name: 11 | description: 'Github name' 12 | required: true 13 | gh_email: 14 | description: 'Github email' 15 | required: true 16 | runs: 17 | using: "composite" 18 | steps: 19 | - name: Use Node.js 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: '20' 23 | - uses: './.github/actions/node-cache' 24 | - name: Install deps 25 | shell: bash 26 | run: npm ci 27 | - name: git config 28 | shell: bash 29 | run: | 30 | git config user.name "${{ inputs.gh_name }}" 31 | git config user.email "${{ inputs.gh_email }}" 32 | - name: Build 33 | shell: bash 34 | run: npx nx run-many -t build 35 | - name: Set npm auth 36 | env: 37 | NPM_TOKEN: ${{ inputs.npm_token }} 38 | shell: bash 39 | run: npm config set '//registry.npmjs.org/:_authToken' "${NPM_TOKEN}" 40 | - name: Release (Version, Changelog, Publish to npm and GitHub) 41 | shell: bash 42 | env: 43 | GH_TOKEN: ${{ inputs.gh_token }} 44 | GITHUB_TOKEN: ${{ inputs.gh_token }} 45 | NODE_AUTH_TOKEN: ${{ inputs.npm_token }} 46 | # force NX to not use legacy peer deps handling 47 | npm_config_legacy_peer_deps: false 48 | run: | 49 | npx nx release version 50 | npx nx run-many --target=build --all --parallel --maxParallel=4 --skipNxCache 51 | npx nx release publish 52 | - name: Tag last-release 53 | shell: bash 54 | run: | 55 | git tag -f last-release 56 | git push origin last-release --force 57 | 58 | -------------------------------------------------------------------------------- /packages/react-core/src/use-load-module.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, useRef } from 'react'; 2 | import { getCachedModule, ExposedScalprumModule, getAppData, processManifest, getScalprum } from '@scalprum/core'; 3 | 4 | export type ModuleDefinition = { 5 | scope: string; 6 | module: string; 7 | importName?: string; 8 | processor?: (item: any) => string[]; 9 | }; 10 | 11 | export function useLoadModule( 12 | { scope, module, importName, processor }: ModuleDefinition, 13 | defaultState: any, 14 | ): [ExposedScalprumModule | undefined, Error | undefined] { 15 | const { manifestLocation } = getAppData(scope); 16 | const [data, setData] = useState(defaultState); 17 | const [error, setError] = useState(); 18 | const { cachedModule } = getCachedModule(scope, module); 19 | const isMounted = useRef(true); 20 | const { pluginStore } = getScalprum(); 21 | useEffect(() => { 22 | if (isMounted.current) { 23 | if (!cachedModule) { 24 | if (manifestLocation) { 25 | processManifest(manifestLocation, scope, module, processor) 26 | .then(async () => { 27 | const Module: ExposedScalprumModule = await pluginStore.getExposedModule(scope, module); 28 | setData(() => Module[importName || 'default']); 29 | }) 30 | .catch((e) => { 31 | setError(() => e); 32 | }); 33 | } 34 | } else { 35 | try { 36 | pluginStore.getExposedModule(scope, module).then((Module) => { 37 | setData(() => Module[importName || 'default']); 38 | }); 39 | } catch (e) { 40 | setError(() => e as Error); 41 | } 42 | } 43 | } 44 | 45 | return () => { 46 | isMounted.current = false; 47 | }; 48 | }, [scope, cachedModule]); 49 | 50 | return [data, error]; 51 | } 52 | -------------------------------------------------------------------------------- /examples/test-app-e2e/src/e2e/test-app/module-loading-errors.cy.ts: -------------------------------------------------------------------------------- 1 | describe('Module error loading handling', () => { 2 | it('should show chunk loading error message', () => { 3 | cy.visit('http://localhost:4200/legacy'); 4 | 5 | // intercept webpack chunk and return 500 response 6 | cy.intercept('GET', 'http://127.0.0.1:8001/exposed-./PreLoadedModule.js', { 7 | statusCode: 500, 8 | }); 9 | 10 | cy.on('uncaught:exception', () => { 11 | // exceptions are expected during this test 12 | // returning false here prevents Cypress from failing the test 13 | return false; 14 | }); 15 | 16 | cy.get('#render-preload-module').click(); 17 | cy.wait(1000); 18 | 19 | cy.contains(`Loading chunk exposed-./PreLoadedModule failed.`).should('exist'); 20 | }); 21 | 22 | it('should handle runtime module error', () => { 23 | cy.on('uncaught:exception', () => { 24 | // exceptions are expected during this test 25 | // returning false here prevents Cypress from failing the test 26 | return false; 27 | }); 28 | cy.visit('http://localhost:4200/runtime-error'); 29 | 30 | // the react app is still active 31 | cy.get('h2').contains('Runtime error route').should('exist'); 32 | // error component is rendered 33 | cy.get('p').contains('Synthetic error message').should('exist'); 34 | }); 35 | 36 | it('should render an error with a message is manifest fetch returned 404', () => { 37 | cy.visit('http://localhost:4200/not-found-error'); 38 | cy.on('uncaught:exception', () => { 39 | // exceptions are expected during this test 40 | // returning false here prevents Cypress from failing the test 41 | return false; 42 | }); 43 | 44 | cy.get('h2').contains('Error loading component').should('exist'); 45 | cy.get('p').contains('Unable to load manifest files at /assets/testPath/foo/bar/nonsense.json! 404: Not Found').should('exist'); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /packages/core/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver). 4 | 5 | ## [0.8.3](https://github.com/scalprum/scaffloding/compare/@scalprum/core-0.8.2...@scalprum/core-0.8.3) (2025-03-31) 6 | 7 | 8 | ### Bug Fixes 9 | 10 | * **core:** do not process manifest for intialized scopes ([dcd4bed](https://github.com/scalprum/scaffloding/commit/dcd4bedeed50f9e17c45fbf46eccd4e270de0771)) 11 | 12 | ## [0.8.2](https://github.com/scalprum/scaffloding/compare/@scalprum/core-0.8.1...@scalprum/core-0.8.2) (2025-03-20) 13 | 14 | ## [0.8.1](https://github.com/scalprum/scaffolding/compare/@scalprum/core-0.8.0...@scalprum/core-0.8.1) (2024-09-11) 15 | 16 | 17 | ### Bug Fixes 18 | 19 | * **core:** ensure scalprum instance receives configuration updates ([9caf092](https://github.com/scalprum/scaffolding/commit/9caf092b741300cfd395b42844e21804204a297c)) 20 | 21 | ## [0.8.0](https://github.com/scalprum/scaffolding/compare/@scalprum/core-0.7.0...@scalprum/core-0.8.0) (2024-09-09) 22 | 23 | 24 | ### Features 25 | 26 | * **core:** allow directly using plugin manifest ([9eede15](https://github.com/scalprum/scaffolding/commit/9eede15da2db3113f480326597f612e8cd853840)) 27 | 28 | ## [0.7.0](https://github.com/scalprum/scaffolding/compare/@scalprum/core-0.6.6...@scalprum/core-0.7.0) (2024-01-22) 29 | 30 | 31 | ### Features 32 | 33 | * support SDK v5 ([aa922b7](https://github.com/scalprum/scaffolding/commit/aa922b710d50c2ae5058a4b11a623c93ce89edcf)) 34 | 35 | ## [0.6.6](https://github.com/scalprum/scaffolding/compare/@scalprum/core-0.6.5...@scalprum/core-0.6.6) (2024-01-22) 36 | 37 | ## [0.6.5](https://github.com/scalprum/scaffolding/compare/@scalprum/core-0.6.4...@scalprum/core-0.6.5) (2023-12-04) 38 | 39 | ## [0.6.4](https://github.com/scalprum/scaffolding/compare/@scalprum/core-0.6.3...@scalprum/core-0.6.4) (2023-12-04) 40 | 41 | ## [0.6.3](https://github.com/scalprum/scaffolding/compare/@scalprum/core-0.6.2...@scalprum/core-0.6.3) (2023-12-04) 42 | 43 | # Changelog 44 | -------------------------------------------------------------------------------- /packages/react-core/cypress.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { ModuleFederationPlugin } = require('@module-federation/enhanced'); 3 | 4 | const ShellConfig = new ModuleFederationPlugin({ 5 | name: 'shell', 6 | filename: 'shell.[contenthash].js', 7 | library: { 8 | type: 'global', 9 | name: 'shell', 10 | }, 11 | shared: [ 12 | { 13 | react: { 14 | singleton: true, 15 | }, 16 | 'react-dom': { 17 | singleton: true, 18 | }, 19 | '@scalprum/react-core': { 20 | singleton: true, 21 | }, 22 | '@openshift/dynamic-plugin-sdk': { 23 | singleton: true, 24 | }, 25 | }, 26 | ], 27 | }); 28 | 29 | const config = { 30 | component: { 31 | videosFolder: '../../dist/cypress/packages/react-core/videos', 32 | screenshotsFolder: '../../dist/cypress/packages/react-core/screenshots', 33 | chromeWebSecurity: false, 34 | specPattern: 'src/**/*.cy.{js,jsx,ts,tsx}', 35 | devServer: { 36 | framework: 'react', 37 | bundler: 'webpack', 38 | webpackConfig: () => { 39 | return { 40 | resolve: { 41 | extensions: ['.tsx', '.ts', '.js'], 42 | alias: { 43 | '@scalprum/core': path.resolve(__dirname, '../core/src/index.ts'), 44 | }, 45 | }, 46 | module: { 47 | rules: [ 48 | { 49 | test: /\.(js|ts)x?$/, 50 | exclude: /node_modules/, 51 | use: { 52 | loader: 'swc-loader', 53 | options: { 54 | jsc: { 55 | parser: { 56 | syntax: 'typescript', 57 | tsx: true, 58 | }, 59 | }, 60 | }, 61 | }, 62 | }, 63 | { 64 | test: /\.css$/, 65 | use: ['style-loader', 'css-loader'], 66 | }, 67 | ], 68 | }, 69 | plugins: [ShellConfig], 70 | }; 71 | }, 72 | }, 73 | }, 74 | }; 75 | 76 | module.exports = config; 77 | -------------------------------------------------------------------------------- /packages/build-utils/src/executors/builder/executor.ts: -------------------------------------------------------------------------------- 1 | import { ExecutorContext } from '@nx/devkit'; 2 | import { z } from 'zod'; 3 | import { stat } from 'fs'; 4 | import { promisify } from 'util'; 5 | import { exec, execSync } from 'child_process'; 6 | 7 | const asyncStat = promisify(stat); 8 | const asyncExec = promisify(exec); 9 | 10 | const BuilderExecutorSchema = z.object({ 11 | esmTsConfig: z.string(), 12 | cjsTsConfig: z.string(), 13 | outputPath: z.string(), 14 | assets: z.array(z.string()).optional(), 15 | }); 16 | 17 | export type BuilderExecutorSchemaType = z.infer; 18 | 19 | async function validateExistingFile(path: string) { 20 | return asyncStat(path); 21 | } 22 | 23 | async function runTSC(tsConfigPath: string, outputDir: string) { 24 | try { 25 | execSync(`tsc -p ${tsConfigPath} --outDir ${outputDir}`, { stdio: 'inherit' }); 26 | } catch (error) { 27 | console.log(error); 28 | throw new Error(`Failed to run tsc for ${tsConfigPath}`); 29 | } 30 | } 31 | 32 | async function copyAssets(assets: string[], outputDir: string) { 33 | return Promise.all(assets.map((asset) => asyncExec(`cp -r ${asset} ${outputDir}`))); 34 | } 35 | 36 | export default async function runExecutor(options: BuilderExecutorSchemaType, context: ExecutorContext) { 37 | try { 38 | BuilderExecutorSchema.parse(options); 39 | } catch (error) { 40 | throw new Error(`Invalid options passed to builder executor: ${error}`); 41 | } 42 | 43 | const projectName = context.projectName; 44 | const projectRoot = context.root; 45 | const currentProjectRoot = context.projectsConfigurations?.projects?.[projectName]?.root; 46 | const projectPackageJsonPath = `${currentProjectRoot}/package.json`; 47 | const outputDir = `${projectRoot}/${options.outputPath}`; 48 | 49 | const assets = [...(options.assets ?? []), projectPackageJsonPath]; 50 | 51 | await Promise.all([ 52 | validateExistingFile(options.esmTsConfig), 53 | validateExistingFile(options.cjsTsConfig), 54 | validateExistingFile(projectPackageJsonPath), 55 | ]); 56 | await Promise.all([runTSC(options.esmTsConfig, `${outputDir}/esm`), runTSC(options.cjsTsConfig, outputDir)]); 57 | await copyAssets(assets, outputDir); 58 | return { 59 | success: true, 60 | }; 61 | } 62 | -------------------------------------------------------------------------------- /federation-cdn-mock/src/modules/useTimerHook.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from 'react'; 2 | 3 | export interface UseTimerOptions { 4 | duration?: number; // in seconds 5 | autoStart?: boolean; 6 | onComplete?: () => void; 7 | } 8 | 9 | export interface UseTimerResult { 10 | timeLeft: number; 11 | isRunning: boolean; 12 | isComplete: boolean; 13 | start: () => void; 14 | pause: () => void; 15 | reset: () => void; 16 | restart: () => void; 17 | } 18 | 19 | /** 20 | * Remote hook that provides timer functionality 21 | * This will be loaded and executed remotely via useRemoteHook 22 | */ 23 | export const useTimerHook = (options: UseTimerOptions = {}): UseTimerResult => { 24 | const { duration = 10, autoStart = false, onComplete } = options; 25 | 26 | const [timeLeft, setTimeLeft] = useState(duration); 27 | const [isRunning, setIsRunning] = useState(autoStart); 28 | const intervalRef = useRef(null); 29 | 30 | const isComplete = timeLeft === 0; 31 | 32 | useEffect(() => { 33 | if (isRunning && timeLeft > 0) { 34 | intervalRef.current = setInterval(() => { 35 | setTimeLeft(prev => { 36 | if (prev <= 1) { 37 | setIsRunning(false); 38 | onComplete?.(); 39 | return 0; 40 | } 41 | return prev - 1; 42 | }); 43 | }, 1000); 44 | } else { 45 | if (intervalRef.current) { 46 | clearInterval(intervalRef.current); 47 | intervalRef.current = null; 48 | } 49 | } 50 | 51 | return () => { 52 | if (intervalRef.current) { 53 | clearInterval(intervalRef.current); 54 | } 55 | }; 56 | }, [isRunning, timeLeft, onComplete]); 57 | 58 | const start = () => { 59 | if (timeLeft > 0) { 60 | setIsRunning(true); 61 | } 62 | }; 63 | 64 | const pause = () => { 65 | setIsRunning(false); 66 | }; 67 | 68 | const reset = () => { 69 | setIsRunning(false); 70 | setTimeLeft(duration); 71 | }; 72 | 73 | const restart = () => { 74 | setTimeLeft(duration); 75 | setIsRunning(true); 76 | }; 77 | 78 | return { 79 | timeLeft, 80 | isRunning, 81 | isComplete, 82 | start, 83 | pause, 84 | reset, 85 | restart, 86 | }; 87 | }; 88 | 89 | export default useTimerHook; -------------------------------------------------------------------------------- /examples/test-app/src/modules/moduleOne.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { usePrefetch } from '@scalprum/react-core'; 3 | import { Box, Button, Card, CardActions, CardContent, CardMedia, Stack, Typography } from '@mui/material'; 4 | 5 | type Prefetch = Record> = (scalprumApi: A) => Promise; 6 | 7 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 8 | // @ts-ignore 9 | window.prefetchCounter = 0; 10 | 11 | export const prefetch: Prefetch = (_scalprumApi) => { 12 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 13 | // @ts-ignore 14 | window.prefetchCounter += 1; 15 | return new Promise((res, rej) => { 16 | setTimeout(() => { 17 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 18 | // @ts-ignore 19 | if (window.prefetchError === true) { 20 | return rej('Expected error'); 21 | } 22 | return res('Hello'); 23 | }, 500); 24 | }); 25 | }; 26 | 27 | const ModuleOne = () => { 28 | const { data, ready, error } = usePrefetch(); 29 | return ( 30 | 31 | 36 | 37 | 38 | Module one remote component 39 | 40 | 41 | Lizards are a widespread group of squamate reptiles, with over 6,000 species, ranging across all continents except Antarctica 42 | 43 | 44 | 45 | {!ready && Loading...} 46 | {ready && data ? {data} : null} 47 | {error ? {error} : null} 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | ); 57 | }; 58 | 59 | export default ModuleOne; 60 | -------------------------------------------------------------------------------- /federation-cdn-mock/src/modules/moduleOne.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { usePrefetch } from '@scalprum/react-core'; 3 | import { Box, Button, Card, CardActions, CardContent, CardMedia, Stack, Typography } from '@mui/material'; 4 | 5 | type Prefetch = Record> = (scalprumApi: A) => Promise; 6 | 7 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 8 | // @ts-ignore 9 | window.prefetchCounter = 0; 10 | 11 | export const prefetch: Prefetch = (_scalprumApi) => { 12 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 13 | // @ts-ignore 14 | window.prefetchCounter += 1; 15 | return new Promise((res, rej) => { 16 | setTimeout(() => { 17 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 18 | // @ts-ignore 19 | if (window.prefetchError === true) { 20 | return rej('Expected error'); 21 | } 22 | return res('Hello'); 23 | }, 500); 24 | }); 25 | }; 26 | 27 | const ModuleOne = () => { 28 | const { data, ready, error } = usePrefetch(); 29 | return ( 30 | 31 | 36 | 37 | 38 | Module one remote component 39 | 40 | 41 | Lizards are a widespread group of squamate reptiles, with over 6,000 species, ranging across all continents except Antarctica 42 | 43 | 44 | 45 | {!ready && Loading...} 46 | {ready && data ? {data} : null} 47 | {error ? {error} : null} 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | ); 57 | }; 58 | 59 | export default ModuleOne; 60 | -------------------------------------------------------------------------------- /packages/react-core/src/ScalprumProvider.cy.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { Scalprum, getScalprum, initialize, removeScalprum } from '@scalprum/core'; 3 | import { ScalprumProvider } from './scalprum-provider'; 4 | import { ScalprumComponent } from './scalprum-component'; 5 | 6 | function mockModule(scalprum: Scalprum, moduleName: string) { 7 | // set new module directly to the exposedModules registry 8 | scalprum.exposedModules[moduleName] = { 9 | default: () =>
{moduleName}
, 10 | }; 11 | } 12 | 13 | describe('ScalprumProvider.cy.tsx', () => { 14 | beforeEach(() => { 15 | removeScalprum(); 16 | }); 17 | it('Should create scalprum provider from scalprum instance', () => { 18 | const scalprum = initialize({ 19 | appsConfig: { 20 | foo: { 21 | manifestLocation: '/foo/manifest.json', 22 | name: 'foo', 23 | }, 24 | }, 25 | }); 26 | 27 | mockModule(scalprum, 'foo#foo'); 28 | cy.mount( 29 | 30 |
Test
31 | 32 |
, 33 | ); 34 | 35 | cy.contains('Test').should('exist'); 36 | cy.contains('foo#foo').should('exist'); 37 | }); 38 | 39 | it('Should create scalprum provider from config props', () => { 40 | const InitComponent = () => { 41 | const [initialized, setInitialized] = React.useState(false); 42 | useEffect(() => { 43 | const scalprum = getScalprum(); 44 | 45 | mockModule(scalprum, 'bar#bar'); 46 | // ensure the mocked module is ready 47 | setTimeout(() => { 48 | setInitialized(true); 49 | }); 50 | }, []); 51 | 52 | if (!initialized) { 53 | return
Not initialized
; 54 | } 55 | 56 | return ; 57 | }; 58 | cy.mount( 59 | 67 |
Test
68 | 69 |
, 70 | ); 71 | 72 | cy.contains('Test').should('exist'); 73 | cy.contains('bar#bar').should('exist'); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /examples/test-app/webpack.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | /* eslint-disable @typescript-eslint/no-explicit-any */ 3 | import { withNx, NxWebpackExecutionContext, composePluginsSync } from '@nx/webpack'; 4 | import { withReact } from '@nx/react'; 5 | import { merge } from 'webpack-merge'; 6 | import { Configuration } from 'webpack'; 7 | import { join } from 'path'; 8 | import { ModuleFederationPlugin } from '@module-federation/enhanced'; 9 | 10 | const ShellConfig = new ModuleFederationPlugin({ 11 | name: 'shell', 12 | filename: 'shell.[contenthash].js', 13 | library: { 14 | type: 'global', 15 | name: 'shell', 16 | }, 17 | shared: [ 18 | { 19 | react: { 20 | singleton: true, 21 | }, 22 | 'react-dom': { 23 | singleton: true, 24 | }, 25 | '@scalprum/react-core': { 26 | singleton: true, 27 | }, 28 | '@openshift/dynamic-plugin-sdk': { 29 | singleton: true, 30 | }, 31 | }, 32 | ], 33 | }); 34 | 35 | const withModuleFederation = (config: Configuration, { context }: NxWebpackExecutionContext): Configuration => { 36 | const plugins: Configuration['plugins'] = [ShellConfig]; 37 | const newConfig = merge(config, { 38 | experiments: { 39 | outputModule: true, 40 | }, 41 | output: { 42 | publicPath: 'auto', 43 | }, 44 | plugins, 45 | }); 46 | // @ts-ignore 47 | if (newConfig.devServer) { 48 | // @ts-ignore 49 | newConfig.devServer.client = { 50 | overlay: false, 51 | }; 52 | } 53 | return newConfig; 54 | }; 55 | 56 | const withWebpackCache = (config: Configuration, { context }: NxWebpackExecutionContext): Configuration => { 57 | return merge(config, { 58 | cache: { 59 | type: 'filesystem', 60 | cacheDirectory: join(context.root, '.webpack-cache'), 61 | }, 62 | }); 63 | }; 64 | 65 | function init(...args: any[]) { 66 | // @ts-ignore 67 | const config = composePluginsSync(withNx(), withReact(), withWebpackCache, withModuleFederation)(...args); 68 | config.plugins?.forEach((plugin) => { 69 | if (plugin?.constructor.name === 'ReactRefreshPlugin') { 70 | // disable annoying overlay 71 | // @ts-ignore 72 | plugin.options.overlay = false; 73 | } 74 | }); 75 | return config; 76 | } 77 | 78 | // Nx plugins for webpack to build config object from Nx options and context. 79 | export default init; 80 | -------------------------------------------------------------------------------- /packages/react-core/src/use-remote-hook.ts: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useReducer, useState, useRef } from 'react'; 2 | import { getModule } from '@scalprum/core'; 3 | import { RemoteHookContext } from './remote-hook-provider'; 4 | import { UseRemoteHookResult } from './remote-hooks-types'; 5 | 6 | export const useRemoteHook = ({ 7 | scope, 8 | module, 9 | importName, 10 | args = [], 11 | }: { 12 | scope: string; 13 | module: string; 14 | importName?: string; 15 | args?: any[]; 16 | }): UseRemoteHookResult => { 17 | const { subscribe, updateState, getState, registerHook, updateArgs } = useContext(RemoteHookContext); 18 | const [, forceUpdate] = useReducer((x) => x + 1, 0); 19 | const [id, setId] = useState(''); 20 | 21 | useEffect(() => { 22 | const { id, unsubscribe } = subscribe(forceUpdate); 23 | setId(id); 24 | 25 | // Track if component is still mounted 26 | let isMounted = true; 27 | 28 | // Load the federated hook module 29 | const loadHook = async () => { 30 | try { 31 | const hookFunction = await getModule(scope, module, importName); 32 | 33 | // Only update if component is still mounted 34 | if (isMounted) { 35 | updateArgs(id, args); // Set args before registering hook 36 | registerHook(id, hookFunction); 37 | } 38 | } catch (error) { 39 | if (isMounted) { 40 | updateState(id, { loading: false, error }); 41 | } 42 | } 43 | }; 44 | 45 | // Set initial loading state 46 | updateState(id, { loading: true, error: null }); 47 | loadHook(); 48 | 49 | return () => { 50 | isMounted = false; // Mark as unmounted 51 | unsubscribe(); 52 | }; 53 | }, [scope, module, importName]); 54 | 55 | // Update args when they change (with shallow comparison) 56 | const argsRef = useRef(args); 57 | useEffect(() => { 58 | if (id) { 59 | const prevArgs = argsRef.current; 60 | const hasChanged = args.length !== prevArgs.length || args.some((arg, index) => arg !== prevArgs[index]); 61 | 62 | if (hasChanged) { 63 | argsRef.current = args; 64 | updateArgs(id, args); 65 | } 66 | } 67 | }, [id, args, updateArgs]); 68 | 69 | const state = getState(id) || { loading: true, error: null }; 70 | 71 | return { 72 | id, 73 | loading: state.loading, 74 | error: state.error, 75 | hookResult: state.hookResult, 76 | }; 77 | }; 78 | -------------------------------------------------------------------------------- /examples/test-app/src/layouts/RootLayout.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import AppBar from '@mui/material/AppBar'; 3 | import Button from '@mui/material/Button'; 4 | import CssBaseline from '@mui/material/CssBaseline'; 5 | import Toolbar from '@mui/material/Toolbar'; 6 | import Typography from '@mui/material/Typography'; 7 | import GlobalStyles from '@mui/material/GlobalStyles'; 8 | import Container from '@mui/material/Container'; 9 | import { Link, Outlet } from 'react-router-dom'; 10 | 11 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 12 | const NavLink = (props: any) => 76 | 79 | 80 |
81 | 82 | 83 | 84 |
85 | {showPreLoadedModule && ( 86 |
87 | 88 | 89 | 90 |
91 | )} 92 | {showPreLoadedModuleWPF && ( 93 |
94 | 95 | 96 | 97 |
98 | )} 99 |
100 | 101 | 102 | ); 103 | }; 104 | 105 | export default LegacyModules; 106 | -------------------------------------------------------------------------------- /packages/react-test-utils/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver). 4 | 5 | ## [0.2.7](https://github.com/scalprum/scaffloding/compare/@scalprum/react-test-utils-0.2.6...@scalprum/react-test-utils-0.2.7) (2025-10-03) 6 | 7 | ### Dependency Updates 8 | 9 | * `@scalprum/react-core` updated to version `0.10.0` 10 | ## [0.2.6](https://github.com/scalprum/scaffloding/compare/@scalprum/react-test-utils-0.2.5...@scalprum/react-test-utils-0.2.6) (2025-03-31) 11 | 12 | ### Dependency Updates 13 | 14 | * `@scalprum/core` updated to version `0.8.3` 15 | * `@scalprum/react-core` updated to version `0.9.5` 16 | ## [0.2.5](https://github.com/scalprum/scaffloding/compare/@scalprum/react-test-utils-0.2.4...@scalprum/react-test-utils-0.2.5) (2025-03-20) 17 | 18 | ### Dependency Updates 19 | 20 | * `@scalprum/core` updated to version `0.8.2` 21 | * `@scalprum/react-core` updated to version `0.9.4` 22 | ## [0.2.4](https://github.com/scalprum/scaffolding/compare/@scalprum/react-test-utils-0.2.3...@scalprum/react-test-utils-0.2.4) (2024-09-24) 23 | 24 | ### Dependency Updates 25 | 26 | * `@scalprum/react-core` updated to version `0.9.3` 27 | ## [0.2.3](https://github.com/scalprum/scaffolding/compare/@scalprum/react-test-utils-0.2.2...@scalprum/react-test-utils-0.2.3) (2024-09-24) 28 | 29 | ### Dependency Updates 30 | 31 | * `@scalprum/react-core` updated to version `0.9.2` 32 | ## [0.2.2](https://github.com/scalprum/scaffolding/compare/@scalprum/react-test-utils-0.2.1...@scalprum/react-test-utils-0.2.2) (2024-09-11) 33 | 34 | ### Dependency Updates 35 | 36 | * `@scalprum/core` updated to version `0.8.1` 37 | * `@scalprum/react-core` updated to version `0.9.1` 38 | ## [0.2.1](https://github.com/scalprum/scaffolding/compare/@scalprum/react-test-utils-0.2.0...@scalprum/react-test-utils-0.2.1) (2024-09-09) 39 | 40 | ### Dependency Updates 41 | 42 | * `@scalprum/core` updated to version `0.8.0` 43 | * `@scalprum/react-core` updated to version `0.9.0` 44 | ## [0.2.0](https://github.com/scalprum/scaffolding/compare/@scalprum/react-test-utils-0.1.3...@scalprum/react-test-utils-0.2.0) (2024-05-30) 45 | 46 | ### Dependency Updates 47 | 48 | * `@scalprum/react-core` updated to version `0.8.0` 49 | 50 | ### Features 51 | 52 | * enable ScalprumProvider intialization with scalprum instance ([894c8bf](https://github.com/scalprum/scaffolding/commit/894c8bf3d9f32a3f2236d8f1fac86a557cd09639)) 53 | 54 | ## [0.1.3](https://github.com/scalprum/scaffolding/compare/@scalprum/react-test-utils-0.1.2...@scalprum/react-test-utils-0.1.3) (2024-01-26) 55 | 56 | ## [0.1.2](https://github.com/scalprum/scaffolding/compare/@scalprum/react-test-utils-0.1.1...@scalprum/react-test-utils-0.1.2) (2024-01-26) 57 | 58 | ### Dependency Updates 59 | 60 | * `@scalprum/react-core` updated to version `0.7.1` 61 | ## [0.1.1](https://github.com/scalprum/scaffolding/compare/@scalprum/react-test-utils-0.1.0...@scalprum/react-test-utils-0.1.1) (2024-01-26) 62 | 63 | ## [0.1.0](https://github.com/scalprum/scaffolding/compare/@scalprum/react-test-utils-0.0.9...@scalprum/react-test-utils-0.1.0) (2024-01-22) 64 | 65 | ### Dependency Updates 66 | 67 | * `@scalprum/core` updated to version `0.7.0` 68 | * `@scalprum/react-core` updated to version `0.7.0` 69 | 70 | ### Features 71 | 72 | * support SDK v5 ([aa922b7](https://github.com/scalprum/scaffolding/commit/aa922b710d50c2ae5058a4b11a623c93ce89edcf)) 73 | 74 | ## [0.0.9](https://github.com/scalprum/scaffolding/compare/@scalprum/react-test-utils-0.0.8...@scalprum/react-test-utils-0.0.9) (2024-01-22) 75 | 76 | ### Dependency Updates 77 | 78 | * `@scalprum/core` updated to version `0.6.6` 79 | * `@scalprum/react-core` updated to version `0.6.7` 80 | ## [0.0.8](https://github.com/scalprum/scaffolding/compare/@scalprum/react-test-utils-0.0.7...@scalprum/react-test-utils-0.0.8) (2024-01-15) 81 | 82 | ### Dependency Updates 83 | 84 | * `@scalprum/react-core` updated to version `0.6.6` 85 | ## [0.0.7](https://github.com/scalprum/scaffolding/compare/@scalprum/react-test-utils-0.0.6...@scalprum/react-test-utils-0.0.7) (2023-12-04) 86 | 87 | ### Dependency Updates 88 | 89 | * `@scalprum/core` updated to version `0.6.5` 90 | * `@scalprum/react-core` updated to version `0.6.5` 91 | ## [0.0.6](https://github.com/scalprum/scaffolding/compare/@scalprum/react-test-utils-0.0.5...@scalprum/react-test-utils-0.0.6) (2023-12-04) 92 | 93 | ### Dependency Updates 94 | 95 | * `@scalprum/core` updated to version `0.6.4` 96 | * `@scalprum/react-core` updated to version `0.6.4` 97 | # Changelog 98 | -------------------------------------------------------------------------------- /federation-cdn-mock/webpack.config.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require('path'); 2 | const { ModuleFederationPlugin, ContainerPlugin } = require('@module-federation/enhanced'); 3 | const { DynamicRemotePlugin } = require('@openshift/dynamic-plugin-sdk-webpack'); 4 | 5 | console.log('Entry tests:', resolve(__dirname, './src/modules/moduleOne.tsx')); 6 | 7 | const sharedModules = { 8 | react: { 9 | singleton: true, 10 | requiredVersion: '*', 11 | version: '18.2.0', 12 | }, 13 | 'react-dom': { 14 | singleton: true, 15 | requiredVersion: '*', 16 | version: '18.2.0', 17 | }, 18 | '@scalprum/core': { 19 | singleton: true, 20 | requiredVersion: '*', 21 | }, 22 | '@scalprum/react-core': { 23 | singleton: true, 24 | requiredVersion: '*', 25 | }, 26 | '@openshift/dynamic-plugin-sdk': { 27 | singleton: true, 28 | requiredVersion: '*', 29 | }, 30 | }; 31 | 32 | const TestSDKPlugin = new DynamicRemotePlugin({ 33 | extensions: [], 34 | sharedModules, 35 | entryScriptFilename: 'sdk-plugin.[contenthash].js', 36 | moduleFederationSettings: { 37 | // Use non native webpack plugins 38 | pluginOverride: { 39 | ModuleFederationPlugin, 40 | ContainerPlugin, 41 | }, 42 | }, 43 | pluginMetadata: { 44 | name: 'sdk-plugin', 45 | version: '1.0.0', 46 | exposedModules: { 47 | './ModuleOne': resolve(__dirname, './src/modules/moduleOne.tsx'), 48 | './ModuleTwo': resolve(__dirname, './src/modules/moduleTwo.tsx'), 49 | './ModuleThree': resolve(__dirname, './src/modules/moduleThree.tsx'), 50 | './ErrorModule': resolve(__dirname, './src/modules/errorModule.tsx'), 51 | './PreLoadedModule': resolve(__dirname, './src/modules/preLoad.tsx'), 52 | './NestedModule': resolve(__dirname, './src/modules/nestedModule.tsx'), 53 | './ModuleThree': resolve(__dirname, './src/modules/moduleThree.tsx'), 54 | './ModuleFour': resolve(__dirname, './src/modules/moduleFour.tsx'), 55 | './SDKComponent': resolve(__dirname, './src/modules/SDKComponent.tsx'), 56 | './ApiModule': resolve(__dirname, './src/modules/apiModule.tsx'), 57 | './DelayedModule': resolve(__dirname, './src/modules/delayedModule.tsx'), 58 | './useCounterHook': resolve(__dirname, './src/modules/useCounterHook.tsx'), 59 | './useApiHook': resolve(__dirname, './src/modules/useApiHook.tsx'), 60 | './useTimerHook': resolve(__dirname, './src/modules/useTimerHook.tsx'), 61 | './useSharedStoreHook': resolve(__dirname, './src/modules/useSharedStoreHook.tsx'), 62 | }, 63 | }, 64 | }); 65 | 66 | const FullManifest = new DynamicRemotePlugin({ 67 | extensions: [], 68 | sharedModules, 69 | pluginManifestFilename: 'full-manifest.json', 70 | entryScriptFilename: 'full-manifest.js', 71 | moduleFederationSettings: { 72 | // Use non native webpack plugins 73 | pluginOverride: { 74 | ModuleFederationPlugin, 75 | ContainerPlugin, 76 | }, 77 | }, 78 | pluginMetadata: { 79 | name: 'full-manifest', 80 | version: '1.0.0', 81 | exposedModules: { 82 | './SDKComponent': resolve(__dirname, './src/modules/SDKComponent.tsx'), 83 | }, 84 | }, 85 | }); 86 | 87 | function init() { 88 | /** @type { import("webpack").Configuration } */ 89 | const config = { 90 | entry: { 91 | mock: resolve(__dirname, './src/index.tsx'), 92 | }, 93 | cache: { type: 'filesystem', cacheDirectory: resolve(__dirname, '.cdn-cache') }, 94 | output: { 95 | publicPath: 'auto', 96 | }, 97 | mode: 'development', 98 | plugins: [TestSDKPlugin, FullManifest], 99 | resolve: { 100 | alias: { 101 | '@scalprum/react-core': resolve(__dirname, '../dist/packages/react-core/esm'), 102 | '@scalprum/core': resolve(__dirname, '../dist/packages/core/esm'), 103 | } 104 | }, 105 | module: { 106 | rules: [ 107 | { 108 | test: /\.tsx?$/, 109 | exclude: /node_modules/, 110 | use: { 111 | loader: 'swc-loader', 112 | options: { 113 | jsc: { 114 | parser: { 115 | syntax: 'typescript', 116 | tsx: true, 117 | }, 118 | }, 119 | }, 120 | }, 121 | }, 122 | ], 123 | }, 124 | }; 125 | 126 | return config; 127 | } 128 | 129 | // Nx plugins for webpack to build config object from Nx options and context. 130 | module.exports = init; 131 | -------------------------------------------------------------------------------- /packages/react-test-utils/src/index.tsx: -------------------------------------------------------------------------------- 1 | import './overrides'; 2 | import { PluginManifest } from '@openshift/dynamic-plugin-sdk'; 3 | import { ScalprumProvider, ScalprumProviderConfigurableProps, useScalprum } from '@scalprum/react-core'; 4 | import { fetch as fetchPolyfill } from 'whatwg-fetch'; 5 | import React, { useEffect } from 'react'; 6 | import { AppsConfig, getModuleIdentifier, getScalprum } from '@scalprum/core'; 7 | 8 | type SharedScope = Record Promise; from: string; eager: boolean }>>; 9 | 10 | declare global { 11 | // eslint-disable-next-line no-var 12 | var __webpack_share_scopes__: SharedScope; 13 | // var fetch: typeof fetchInternal; 14 | } 15 | 16 | export function mockWebpackShareScope() { 17 | const __webpack_share_scopes__: SharedScope = { 18 | default: {}, 19 | }; 20 | globalThis.__webpack_share_scopes__ = __webpack_share_scopes__; 21 | } 22 | 23 | export function mockFetch() { 24 | if (!globalThis.fetch) { 25 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 26 | // @ts-ignore 27 | globalThis.fetch = fetchPolyfill; 28 | } 29 | } 30 | 31 | export function mockScalprum() { 32 | mockWebpackShareScope(); 33 | mockFetch(); 34 | } 35 | 36 | type ModuleMock = { 37 | [importName: string]: React.ComponentType; 38 | }; 39 | 40 | const ScalprumInitGate: React.ComponentType< 41 | React.PropsWithChildren<{ 42 | moduleMock: ModuleMock; 43 | pluginManifest: PluginManifest; 44 | moduleName: string; 45 | }> 46 | > = ({ children, moduleMock, pluginManifest, moduleName }) => { 47 | const scalprum = useScalprum(); 48 | const [mockReady, setMockReady] = React.useState(false); 49 | const { initialized } = scalprum; 50 | useEffect(() => { 51 | if (initialized && !mockReady) { 52 | const scalprum = getScalprum(); 53 | scalprum.exposedModules[getModuleIdentifier(pluginManifest.name, moduleName)] = moduleMock; 54 | setMockReady(true); 55 | } 56 | }, [initialized, mockReady]); 57 | if (!initialized || !mockReady) { 58 | return null; 59 | } 60 | 61 | return <>{children}; 62 | }; 63 | 64 | export const DEFAULT_MODULE_TEST_ID = 'default-module-test-id'; 65 | 66 | export function mockPluginData( 67 | { 68 | headers = new Headers(), 69 | url = 'http://localhost:3000/test-plugin/plugin-manifest.json', 70 | type = 'default', 71 | ok = true, 72 | status = 200, 73 | statusText = 'OK', 74 | pluginManifest = { 75 | baseURL: 'http://localhost:3000', 76 | extensions: [], 77 | loadScripts: [], 78 | name: 'test-plugin', 79 | version: '1.0.0', 80 | registrationMethod: 'custom', 81 | }, 82 | module = 'ExposedModule', 83 | moduleMock = { 84 | default: () =>
Default module
, 85 | }, 86 | config = { 87 | [pluginManifest.name]: { 88 | name: pluginManifest.name, 89 | manifestLocation: url, 90 | }, 91 | }, 92 | }: { 93 | headers?: Headers; 94 | url?: string; 95 | type?: ResponseType; 96 | ok?: boolean; 97 | status?: number; 98 | statusText?: string; 99 | pluginManifest?: PluginManifest; 100 | module?: string; 101 | moduleMock?: ModuleMock; 102 | config?: AppsConfig; 103 | } = {}, 104 | api: ScalprumProviderConfigurableProps['api'] = {}, 105 | ) { 106 | const response: Response = { 107 | blob: () => Promise.resolve(new Blob()), 108 | formData: () => Promise.resolve(new FormData()), 109 | headers, 110 | ok, 111 | redirected: false, 112 | status, 113 | statusText, 114 | url, 115 | type, 116 | body: null, 117 | bodyUsed: false, 118 | bytes: () => Promise.resolve(new Uint8Array()), 119 | arrayBuffer: () => { 120 | return Promise.resolve(new ArrayBuffer(0)); 121 | }, 122 | text() { 123 | return Promise.resolve(JSON.stringify(pluginManifest)); 124 | }, 125 | json: () => { 126 | return Promise.resolve(pluginManifest); 127 | }, 128 | clone: () => response, 129 | }; 130 | 131 | // eslint-disable-next-line @typescript-eslint/ban-types 132 | const TestScalprumProvider: React.ComponentType> = ({ children }) => { 133 | return ( 134 | 135 | 136 | {children} 137 | 138 | 139 | ); 140 | }; 141 | 142 | return { response, TestScalprumProvider }; 143 | } 144 | 145 | mockScalprum(); 146 | -------------------------------------------------------------------------------- /examples/test-app/src/modules/preLoad.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { styled } from '@mui/material/styles'; 3 | import Card from '@mui/material/Card'; 4 | import CardHeader from '@mui/material/CardHeader'; 5 | import CardMedia from '@mui/material/CardMedia'; 6 | import CardContent from '@mui/material/CardContent'; 7 | import CardActions from '@mui/material/CardActions'; 8 | import Collapse from '@mui/material/Collapse'; 9 | import Avatar from '@mui/material/Avatar'; 10 | import IconButton, { IconButtonProps } from '@mui/material/IconButton'; 11 | import Typography from '@mui/material/Typography'; 12 | import { red } from '@mui/material/colors'; 13 | import FavoriteIcon from '@mui/icons-material/Favorite'; 14 | import ShareIcon from '@mui/icons-material/Share'; 15 | import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; 16 | import MoreVertIcon from '@mui/icons-material/MoreVert'; 17 | 18 | interface ExpandMoreProps extends IconButtonProps { 19 | expand: boolean; 20 | } 21 | 22 | const ExpandMore = styled((props: ExpandMoreProps) => { 23 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 24 | const { expand, ...other } = props; 25 | return ; 26 | })(({ theme, expand }) => ({ 27 | transform: !expand ? 'rotate(0deg)' : 'rotate(180deg)', 28 | marginLeft: 'auto', 29 | transition: theme.transitions.create('transform', { 30 | duration: theme.transitions.duration.shortest, 31 | }), 32 | })); 33 | 34 | const PreLoad = () => { 35 | const [expanded, setExpanded] = React.useState(false); 36 | 37 | const handleExpandClick = () => { 38 | setExpanded(!expanded); 39 | }; 40 | 41 | return ( 42 | 43 | 46 | R 47 | 48 | } 49 | action={ 50 | 51 | 52 | 53 | } 54 | title={ 55 | 56 | This module is supposed to be pre-loaded 57 | 58 | } 59 | subheader="September 14, 2016" 60 | /> 61 | 67 | 68 | 69 | This impressive paella is a perfect party dish and a fun meal to cook together with your guests. Add 1 cup of frozen peas along with the 70 | mussels, if you like. 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | Method: 87 | Heat 1/2 cup of the broth in a pot until simmering, add saffron and set aside for 10 minutes. 88 | 89 | Heat oil in a (14- to 16-inch) paella pan or a large, deep skillet over medium-high heat. Add chicken, shrimp and chorizo, and cook, 90 | stirring occasionally until lightly browned, 6 to 8 minutes. Transfer shrimp to a large plate and set aside, leaving chicken and chorizo 91 | in the pan. Add pimentón, bay leaves, garlic, tomatoes, onion, salt and pepper, and cook, stirring often until thickened and fragrant, 92 | about 10 minutes. Add saffron broth and remaining 4 1/2 cups chicken broth; bring to a boil. 93 | 94 | 95 | Add rice and stir very gently to distribute. Top with artichokes and peppers, and cook without stirring, until most of the liquid is 96 | absorbed, 15 to 18 minutes. Reduce heat to medium-low, add reserved shrimp and mussels, tucking them down into the rice, and cook again 97 | without stirring, until mussels have opened and rice is just tender, 5 to 7 minutes more. (Discard any mussels that don't open.) 98 | 99 | Set aside off of the heat to let rest for 10 minutes, and then serve. 100 | 101 | 102 | 103 | ); 104 | }; 105 | 106 | export default PreLoad; 107 | -------------------------------------------------------------------------------- /federation-cdn-mock/src/modules/preLoad.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { styled } from '@mui/material/styles'; 3 | import Card from '@mui/material/Card'; 4 | import CardHeader from '@mui/material/CardHeader'; 5 | import CardMedia from '@mui/material/CardMedia'; 6 | import CardContent from '@mui/material/CardContent'; 7 | import CardActions from '@mui/material/CardActions'; 8 | import Collapse from '@mui/material/Collapse'; 9 | import Avatar from '@mui/material/Avatar'; 10 | import IconButton, { IconButtonProps } from '@mui/material/IconButton'; 11 | import Typography from '@mui/material/Typography'; 12 | import { red } from '@mui/material/colors'; 13 | import FavoriteIcon from '@mui/icons-material/Favorite'; 14 | import ShareIcon from '@mui/icons-material/Share'; 15 | import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; 16 | import MoreVertIcon from '@mui/icons-material/MoreVert'; 17 | 18 | interface ExpandMoreProps extends IconButtonProps { 19 | expand: boolean; 20 | } 21 | 22 | const ExpandMore = styled((props: ExpandMoreProps) => { 23 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 24 | const { expand, ...other } = props; 25 | return ; 26 | })(({ theme, expand }) => ({ 27 | transform: !expand ? 'rotate(0deg)' : 'rotate(180deg)', 28 | marginLeft: 'auto', 29 | transition: theme.transitions.create('transform', { 30 | duration: theme.transitions.duration.shortest, 31 | }), 32 | })); 33 | 34 | const PreLoad = () => { 35 | const [expanded, setExpanded] = React.useState(false); 36 | 37 | const handleExpandClick = () => { 38 | setExpanded(!expanded); 39 | }; 40 | 41 | return ( 42 | 43 | 46 | R 47 | 48 | } 49 | action={ 50 | 51 | 52 | 53 | } 54 | title={ 55 | 56 | This module is supposed to be pre-loaded 57 | 58 | } 59 | subheader="September 14, 2016" 60 | /> 61 | 67 | 68 | 69 | This impressive paella is a perfect party dish and a fun meal to cook together with your guests. Add 1 cup of frozen peas along with the 70 | mussels, if you like. 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | Method: 87 | Heat 1/2 cup of the broth in a pot until simmering, add saffron and set aside for 10 minutes. 88 | 89 | Heat oil in a (14- to 16-inch) paella pan or a large, deep skillet over medium-high heat. Add chicken, shrimp and chorizo, and cook, 90 | stirring occasionally until lightly browned, 6 to 8 minutes. Transfer shrimp to a large plate and set aside, leaving chicken and chorizo 91 | in the pan. Add pimentón, bay leaves, garlic, tomatoes, onion, salt and pepper, and cook, stirring often until thickened and fragrant, 92 | about 10 minutes. Add saffron broth and remaining 4 1/2 cups chicken broth; bring to a boil. 93 | 94 | 95 | Add rice and stir very gently to distribute. Top with artichokes and peppers, and cook without stirring, until most of the liquid is 96 | absorbed, 15 to 18 minutes. Reduce heat to medium-low, add reserved shrimp and mussels, tucking them down into the rice, and cook again 97 | without stirring, until mussels have opened and rice is just tender, 5 to 7 minutes more. (Discard any mussels that don't open.) 98 | 99 | Set aside off of the heat to let rest for 10 minutes, and then serve. 100 | 101 | 102 | 103 | ); 104 | }; 105 | 106 | export default PreLoad; 107 | -------------------------------------------------------------------------------- /examples/test-app-e2e/src/e2e/test-app/remote-hooks.cy.ts: -------------------------------------------------------------------------------- 1 | describe('useRemoteHook functionality', () => { 2 | beforeEach(() => { 3 | cy.handleMetaError(); 4 | }); 5 | 6 | it('should load and display remote hooks page', () => { 7 | cy.visit('http://localhost:4200/remote-hooks'); 8 | cy.contains('Remote Hooks Testing').should('exist'); 9 | cy.contains('Testing useRemoteHook functionality with various hook types').should('exist'); 10 | }); 11 | 12 | it('should load counter hook and allow interactions', () => { 13 | cy.visit('http://localhost:4200/remote-hooks'); 14 | 15 | // Wait for counter hook to load 16 | cy.get('[data-testid="counter-loading"]').should('exist'); 17 | cy.get('[data-testid="counter-value"]', { timeout: 10000 }).should('exist'); 18 | 19 | // Check initial value (should be 5 based on our config) 20 | cy.get('[data-testid="counter-value"]').should('contain', '5'); 21 | 22 | // Test increment (step is 2) 23 | cy.get('[data-testid="counter-increment"]').click(); 24 | cy.get('[data-testid="counter-value"]').should('contain', '7'); 25 | 26 | // Test decrement 27 | cy.get('[data-testid="counter-decrement"]').click(); 28 | cy.get('[data-testid="counter-value"]').should('contain', '5'); 29 | 30 | // Test set to 100 31 | cy.get('[data-testid="counter-set-100"]').click(); 32 | cy.get('[data-testid="counter-value"]').should('contain', '100'); 33 | 34 | // Test reset 35 | cy.get('[data-testid="counter-reset"]').click(); 36 | cy.get('[data-testid="counter-value"]').should('contain', '5'); 37 | }); 38 | 39 | it('should load API hook and handle data fetching', () => { 40 | cy.visit('http://localhost:4200/remote-hooks'); 41 | 42 | // Wait for API hook to load 43 | cy.get('[data-testid="api-loading"]').should('exist'); 44 | cy.get('[data-testid="api-data"]', { timeout: 10000 }).should('exist'); 45 | 46 | // Check that data is displayed 47 | cy.get('[data-testid="api-data"]').should('contain', 'Hello from remote API!'); 48 | 49 | // Test refetch functionality 50 | cy.get('[data-testid="api-refetch"]').click(); 51 | cy.get('[data-testid="api-data-loading"]').should('exist'); 52 | cy.get('[data-testid="api-data"]', { timeout: 5000 }).should('exist'); 53 | }); 54 | 55 | it('should handle API hook error states', () => { 56 | cy.visit('http://localhost:4200/remote-hooks'); 57 | 58 | // Wait for initial load 59 | cy.get('[data-testid="api-data"]', { timeout: 10000 }).should('exist'); 60 | 61 | // Toggle to fail mode 62 | cy.get('[data-testid="api-toggle-fail"]').click(); 63 | cy.get('[data-testid="api-toggle-fail"]').should('contain', 'Make Succeed'); 64 | 65 | // Refetch to trigger error 66 | cy.get('[data-testid="api-refetch"]').click(); 67 | cy.get('[data-testid="api-data-error"]', { timeout: 5000 }).should('exist'); 68 | cy.get('[data-testid="api-data-error"]').should('contain', 'Failed to fetch data'); 69 | 70 | // Toggle back to success 71 | cy.get('[data-testid="api-toggle-fail"]').click(); 72 | cy.get('[data-testid="api-toggle-fail"]').should('contain', 'Make Fail'); 73 | 74 | // Refetch to get success 75 | cy.get('[data-testid="api-refetch"]').click(); 76 | cy.get('[data-testid="api-data"]', { timeout: 5000 }).should('exist'); 77 | }); 78 | 79 | it('should load timer hook and control timer', () => { 80 | cy.visit('http://localhost:4200/remote-hooks'); 81 | 82 | // Wait for timer hook to load 83 | cy.get('[data-testid="timer-loading"]').should('exist'); 84 | cy.get('[data-testid="timer-value"]', { timeout: 10000 }).should('exist'); 85 | 86 | // Check initial state (5 seconds, stopped) 87 | cy.get('[data-testid="timer-value"]').should('contain', '5s'); 88 | cy.get('[data-testid="timer-status"]').should('contain', 'Stopped'); 89 | 90 | // Start timer 91 | cy.get('[data-testid="timer-start"]').click(); 92 | cy.get('[data-testid="timer-status"]').should('contain', 'Running'); 93 | 94 | // Wait a bit and check that time decreased 95 | cy.wait(1500); 96 | cy.get('[data-testid="timer-value"]').should('not.contain', '5s'); 97 | 98 | // Pause timer 99 | cy.get('[data-testid="timer-pause"]').click(); 100 | cy.get('[data-testid="timer-status"]').should('contain', 'Stopped'); 101 | 102 | // Reset timer 103 | cy.get('[data-testid="timer-reset"]').click(); 104 | cy.get('[data-testid="timer-value"]').should('contain', '5s'); 105 | cy.get('[data-testid="timer-status"]').should('contain', 'Stopped'); 106 | 107 | // Test restart 108 | cy.get('[data-testid="timer-restart"]').click(); 109 | cy.get('[data-testid="timer-status"]').should('contain', 'Running'); 110 | }); 111 | 112 | it('should display debug information', () => { 113 | cy.visit('http://localhost:4200/remote-hooks'); 114 | 115 | // Wait for hooks to load 116 | cy.get('[data-testid="counter-value"]', { timeout: 10000 }).should('exist'); 117 | cy.get('[data-testid="api-data"]', { timeout: 10000 }).should('exist'); 118 | cy.get('[data-testid="timer-value"]', { timeout: 10000 }).should('exist'); 119 | 120 | // Check debug info 121 | cy.get('[data-testid="debug-info"]').should('exist'); 122 | cy.get('[data-testid="debug-info"]').should('contain', '"loading": false'); 123 | cy.get('[data-testid="debug-info"]').should('contain', '"hasResult": true'); 124 | }); 125 | 126 | it('should handle hook loading errors gracefully', () => { 127 | cy.visit('http://localhost:4200/remote-hooks'); 128 | 129 | // All hooks should eventually load successfully 130 | cy.get('[data-testid="counter-value"]', { timeout: 10000 }).should('exist'); 131 | cy.get('[data-testid="api-data"]', { timeout: 10000 }).should('exist'); 132 | cy.get('[data-testid="timer-value"]', { timeout: 10000 }).should('exist'); 133 | 134 | // No error states should be visible initially 135 | cy.get('[data-testid="counter-error"]').should('not.exist'); 136 | cy.get('[data-testid="timer-error"]').should('not.exist'); 137 | cy.get('[data-testid="api-error"]').should('not.exist'); 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /packages/react-core/docs/remote-hook-provider.md: -------------------------------------------------------------------------------- 1 | # RemoteHookProvider 2 | 3 | The `RemoteHookProvider` is a React context provider that enables remote hook functionality across your application. It must be wrapped around any components that use `useRemoteHook` or `useRemoteHookManager`. 4 | 5 | ## Quick Reference 6 | 7 | ```tsx 8 | import { ScalprumProvider } from '@scalprum/react-core'; 9 | 10 | // RemoteHookProvider is automatically included in ScalprumProvider 11 | function App() { 12 | return ( 13 | 14 | {/* Remote hooks work automatically here */} 15 | 16 | 17 | ); 18 | } 19 | ``` 20 | 21 | **Key points:** 22 | - Automatically included in `ScalprumProvider` - no setup needed 23 | - Can be used standalone for advanced use cases 24 | - Provides isolated execution environments for each remote hook 25 | - Manages hook state, lifecycle, and argument updates 26 | 27 | ## Overview 28 | 29 | The RemoteHookProvider manages the execution of remote hooks by: 30 | - Creating isolated execution environments for each remote hook 31 | - Managing hook state and lifecycle 32 | - Providing argument updates and subscription mechanisms 33 | - Handling cleanup when hooks are removed 34 | 35 | ## Setup 36 | 37 | The RemoteHookProvider is automatically included when you use ScalprumProvider, so no additional setup is required in most cases. 38 | 39 | ```tsx 40 | import { ScalprumProvider } from '@scalprum/react-core'; 41 | 42 | function App() { 43 | return ( 44 | 45 | {/* Your app components can now use remote hooks */} 46 | 47 | 48 | ); 49 | } 50 | ``` 51 | 52 | ## Manual Setup 53 | 54 | If you need to use RemoteHookProvider somewhere deeper within the the tree (to allow access of hooks to some additional context): 55 | 56 | ```tsx 57 | import { RemoteHookProvider } from '@scalprum/react-core'; 58 | 59 | function App() { 60 | return ( 61 | 62 | {/* Components using remote hooks */} 63 | 64 | 65 | ); 66 | } 67 | ``` 68 | 69 | ## How It Works 70 | 71 | ### Hook Execution 72 | 73 | The provider uses a unique approach to execute remote hooks: 74 | 75 | 1. **Fake Components**: Remote hooks are executed within hidden "fake" components that follow React's rules of hooks 76 | 2. **State Management**: Each hook gets a unique ID and isolated state management 77 | 3. **Argument Updates**: Hook arguments can be updated dynamically without remounting the component 78 | 4. **Subscription Model**: Components subscribe to hook state changes for reactive updates 79 | 80 | ### Internal Architecture 81 | 82 | ```tsx 83 | // Internal hook executor component 84 | function HookExecutor({ id, hookFunction, initialArgs }) { 85 | const [args, setArgs] = useState(initialArgs); 86 | 87 | // Subscribe to argument changes 88 | useEffect(() => { 89 | const unsubscribe = subscribeToArgs(id, setArgs); 90 | return unsubscribe; 91 | }, [id]); 92 | 93 | // Execute the hook with current args 94 | const hookResult = hookFunction(...args); 95 | 96 | // Update the provider state 97 | useEffect(() => { 98 | updateState(id, { hookResult }); 99 | }, [hookResult]); 100 | 101 | return null; // Hidden component 102 | } 103 | ``` 104 | 105 | ## Context API 106 | 107 | The RemoteHookProvider exposes the following context methods: 108 | 109 | ### `subscribe(notify: () => void)` 110 | 111 | Subscribe to state changes for a hook. 112 | 113 | **Parameters:** 114 | - `notify`: Callback function to trigger re-renders 115 | 116 | **Returns:** 117 | - Object with `id` and `unsubscribe` function 118 | 119 | ### `updateState(id: string, value: any)` 120 | 121 | Update the state for a specific hook. 122 | 123 | **Parameters:** 124 | - `id`: Unique hook identifier 125 | - `value`: State updates to merge 126 | 127 | ### `getState(id: string)` 128 | 129 | Get the current state for a specific hook. 130 | 131 | **Parameters:** 132 | - `id`: Unique hook identifier 133 | 134 | **Returns:** 135 | - Current hook state object 136 | 137 | ### `registerHook(id: string, hookFunction: (...args: any[]) => any)` 138 | 139 | Register a remote hook function for execution. 140 | 141 | **Parameters:** 142 | - `id`: Unique hook identifier 143 | - `hookFunction`: The loaded remote hook function 144 | 145 | ### `updateArgs(id: string, args: any[])` 146 | 147 | Update arguments for a specific hook. 148 | 149 | **Parameters:** 150 | - `id`: Unique hook identifier 151 | - `args`: New arguments array 152 | 153 | ### `subscribeToArgs(id: string, callback: (args: any[]) => void)` 154 | 155 | Subscribe to argument changes for a specific hook. 156 | 157 | **Parameters:** 158 | - `id`: Unique hook identifier 159 | - `callback`: Function called when arguments change 160 | 161 | **Returns:** 162 | - Unsubscribe function 163 | 164 | ## Error Handling 165 | 166 | The provider includes error handling for: 167 | - Hook execution errors 168 | - Argument update failures 169 | - Subscription callback errors 170 | 171 | Errors are logged to the console and propagated to the consuming components through the error state. 172 | 173 | ## Performance Considerations 174 | 175 | - **Isolated Execution**: Each hook runs in its own component to prevent interference 176 | - **Efficient Updates**: Only components subscribed to specific hooks re-render on changes 177 | - **Memory Management**: Automatic cleanup prevents memory leaks when hooks are removed 178 | - **Shallow Comparison**: Argument changes use shallow comparison to optimize updates 179 | 180 | ## Cleanup 181 | 182 | The provider automatically handles cleanup when: 183 | - The provider unmounts 184 | - Individual hooks are removed 185 | - Components using hooks unmount 186 | 187 | This prevents memory leaks and ensures proper resource management. 188 | 189 | ## See Also 190 | 191 | - [useRemoteHook](./use-remote-hook.md) - Hook for loading and using individual remote hooks 192 | - [useRemoteHookManager](./use-remote-hook-manager.md) - Hook for managing multiple remote hooks dynamically 193 | - [Remote Hook Types](./remote-hook-types.md) - TypeScript interfaces and types -------------------------------------------------------------------------------- /packages/core/src/scalprum.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import { PluginManifest } from '@openshift/dynamic-plugin-sdk'; 3 | import { initialize, getScalprum, getCachedModule, initSharedScope } from './scalprum'; 4 | 5 | describe('scalprum', () => { 6 | const testManifest: PluginManifest = { 7 | extensions: [], 8 | loadScripts: [], 9 | name: 'testScope', 10 | registrationMethod: 'custom', 11 | version: '1.0.0', 12 | baseURL: '/foo/bar', 13 | }; 14 | const mockInitializeConfig = { 15 | appsConfig: { 16 | appOne: { 17 | name: 'appOne', 18 | appId: 'app-one', 19 | elementId: 'app-one-element', 20 | rootLocation: '/foo/bar', 21 | scriptLocation: '/appOne/url', 22 | }, 23 | appTwo: { 24 | name: 'appTwo', 25 | appId: 'app-two', 26 | elementId: 'app-two-element', 27 | rootLocation: '/foo/bar', 28 | scriptLocation: '/appTwo/url', 29 | }, 30 | appThree: { 31 | name: 'appThree', 32 | appId: 'app-three', 33 | elementId: 'app-three-element', 34 | rootLocation: '/foo/bar', 35 | manifestLocation: '/appThree/url', 36 | }, 37 | appFour: { 38 | name: 'appFour', 39 | appId: 'app-four', 40 | elementId: 'app-four-element', 41 | rootLocation: '/foo/bar', 42 | pluginManifest: testManifest, 43 | }, 44 | appFive: { 45 | name: 'appFive', 46 | appId: 'app-five', 47 | elementId: 'app-five-element', 48 | rootLocation: '/foo/bar', 49 | manifestLocation: '/appFive/url', 50 | pluginManifest: testManifest, 51 | }, 52 | }, 53 | }; 54 | 55 | beforeAll(() => { 56 | // @ts-ignore 57 | global.__webpack_share_scopes__ = { 58 | default: {}, 59 | }; 60 | // @ts-ignore 61 | global.__webpack_init_sharing__ = () => undefined; 62 | }); 63 | 64 | beforeEach(() => { 65 | initSharedScope(); 66 | }); 67 | 68 | test('should initialize scalprum with apps config', () => { 69 | initialize(mockInitializeConfig); 70 | 71 | const expectedResult = { 72 | appsConfig: { 73 | appOne: { appId: 'app-one', elementId: 'app-one-element', name: 'appOne', rootLocation: '/foo/bar', scriptLocation: '/appOne/url' }, 74 | appTwo: { appId: 'app-two', elementId: 'app-two-element', name: 'appTwo', rootLocation: '/foo/bar', scriptLocation: '/appTwo/url' }, 75 | appThree: { 76 | appId: 'app-three', 77 | elementId: 'app-three-element', 78 | name: 'appThree', 79 | rootLocation: '/foo/bar', 80 | manifestLocation: '/appThree/url', 81 | }, 82 | appFour: { 83 | appId: 'app-four', 84 | elementId: 'app-four-element', 85 | name: 'appFour', 86 | rootLocation: '/foo/bar', 87 | pluginManifest: { 88 | baseURL: '/foo/bar', 89 | extensions: [], 90 | loadScripts: [], 91 | name: 'testScope', 92 | registrationMethod: 'custom', 93 | version: '1.0.0', 94 | }, 95 | }, 96 | appFive: { 97 | appId: 'app-five', 98 | elementId: 'app-five-element', 99 | name: 'appFive', 100 | rootLocation: '/foo/bar', 101 | manifestLocation: '/appFive/url', 102 | pluginManifest: { 103 | baseURL: '/foo/bar', 104 | extensions: [], 105 | loadScripts: [], 106 | name: 'testScope', 107 | registrationMethod: 'custom', 108 | version: '1.0.0', 109 | }, 110 | }, 111 | }, 112 | exposedModules: {}, 113 | pendingInjections: {}, 114 | pendingLoading: {}, 115 | pendingPrefetch: {}, 116 | existingScopes: new Set(), 117 | api: {}, 118 | scalprumOptions: { 119 | cacheTimeout: 120, 120 | enableScopeWarning: false, 121 | }, 122 | pluginStore: expect.any(Object), 123 | }; 124 | 125 | // @ts-ignore 126 | expect(getScalprum()).toEqual(expectedResult); 127 | }); 128 | 129 | test('getScalprum should return the scalprum object', () => { 130 | initialize(mockInitializeConfig); 131 | const result = getScalprum(); 132 | expect(result).toEqual(expect.any(Object)); 133 | }); 134 | 135 | test('async loader should cache the webpack container factory', async () => { 136 | const expectedPlugins = [ 137 | { 138 | disableReason: undefined, 139 | enabled: true, 140 | manifest: { baseURL: '/foo/bar', extensions: [], loadScripts: [], registrationMethod: 'custom', name: 'testScope', version: '1.0.0' }, 141 | status: 'loaded', 142 | }, 143 | ]; 144 | initialize(mockInitializeConfig); 145 | // @ts-ignore 146 | global.testScope = { 147 | init: jest.fn(), 148 | get: jest.fn().mockReturnValue(jest.fn().mockReturnValue(jest.fn())), 149 | }; 150 | await getScalprum().pluginStore.loadPlugin(testManifest); 151 | expect(getScalprum().pluginStore.getPluginInfo()).toEqual(expectedPlugins); 152 | }); 153 | 154 | test('getCachedModule should invalidate cache after 120s', async () => { 155 | jest.useFakeTimers(); 156 | initialize(mockInitializeConfig); 157 | // @ts-ignore 158 | global.testScope = { 159 | init: jest.fn(), 160 | get: jest.fn().mockReturnValue(jest.fn().mockReturnValue(jest.fn())), 161 | }; 162 | await getScalprum().pluginStore.loadPlugin(testManifest); 163 | // @ts-ignore 164 | expect(getCachedModule('testScope', './testModule')).toHaveProperty('cachedModule'); 165 | /** 166 | * Advance time by 120s + 1ms 167 | */ 168 | jest.advanceTimersByTime(120 * 1000 + 1); 169 | expect(getCachedModule('testScope', './testModule')).toEqual({}); 170 | }); 171 | 172 | test('getCachedModule should skip factory cache', async () => { 173 | jest.useFakeTimers(); 174 | initialize(mockInitializeConfig); 175 | // @ts-ignore 176 | global.testScope = { 177 | init: jest.fn(), 178 | get: jest.fn().mockReturnValue(jest.fn()), 179 | }; 180 | await getScalprum().pluginStore.loadPlugin(testManifest); 181 | // @ts-ignore 182 | expect(getCachedModule('testScope', './testModule', true)).toEqual({}); 183 | }); 184 | }); 185 | -------------------------------------------------------------------------------- /packages/react-core/src/remote-hook-provider.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, PropsWithChildren, useCallback, useContext, useEffect, useMemo, useState } from 'react'; 2 | import { RemoteHookContextType } from './remote-hooks-types'; 3 | 4 | type StateEntry = { 5 | id: string; 6 | value: any; 7 | notify: () => void; 8 | }; 9 | 10 | type ArgSubscription = { 11 | id: string; 12 | args: any[]; 13 | argNotifiers: Set<(args: any[]) => void>; 14 | }; 15 | 16 | export const RemoteHookContext = createContext({ 17 | subscribe: () => ({ id: '', unsubscribe: () => undefined }), 18 | updateState: () => undefined, 19 | getState: () => undefined, 20 | registerHook: () => undefined, 21 | updateArgs: () => undefined, 22 | subscribeToArgs: () => () => undefined, 23 | }); 24 | 25 | // Fake component that executes the remote hook 26 | function HookExecutor({ 27 | id, 28 | hookFunction, 29 | updateState, 30 | initialArgs, 31 | }: { 32 | id: string; 33 | hookFunction: (...args: any[]) => any; 34 | updateState: (id: string, value: any) => void; 35 | initialArgs: any[]; 36 | }) { 37 | const { subscribeToArgs } = useContext(RemoteHookContext); 38 | const [args, setArgs] = useState(initialArgs); 39 | 40 | // Subscribe to argument changes 41 | useEffect(() => { 42 | const unsubscribe = subscribeToArgs(id, setArgs); 43 | return () => { 44 | unsubscribe(); 45 | }; 46 | }, [id, subscribeToArgs]); 47 | 48 | // Always call the hook with args (rules of hooks) 49 | const hookResult = hookFunction(...args); 50 | 51 | // Update state with the result 52 | useEffect(() => { 53 | // always set loading to false when we have a result 54 | updateState(id, { loading: false, hookResult }); 55 | }, [hookResult, id, updateState]); 56 | 57 | return null; 58 | } 59 | 60 | export const RemoteHookProvider = ({ children }: PropsWithChildren) => { 61 | const state = useMemo(() => ({}), []) as { [id: string]: StateEntry }; 62 | 63 | // React state to track available hooks (for re-rendering) 64 | const [availableHooks, setAvailableHooks] = useState<{ [id: string]: (...args: any[]) => any }>({}); 65 | 66 | // Mutable state for arguments (no re-renders) 67 | const argSubscriptions = useMemo(() => ({}), []) as { [id: string]: ArgSubscription }; 68 | 69 | // Cleanup all subscriptions when provider unmounts 70 | useEffect(() => { 71 | return () => { 72 | // Clear state 73 | Object.keys(state).forEach((id) => { 74 | delete state[id]; 75 | }); 76 | // Clear available hooks (this stops rendering HookExecutors) 77 | setAvailableHooks({}); 78 | // Clear arg subscriptions 79 | Object.keys(argSubscriptions).forEach((id) => { 80 | delete argSubscriptions[id]; 81 | }); 82 | }; 83 | }, [state, argSubscriptions]); 84 | 85 | const subscribe = useCallback( 86 | (notify: () => void) => { 87 | const id = crypto.randomUUID(); 88 | state[id] = { id, value: 0, notify }; 89 | 90 | return { 91 | id, 92 | unsubscribe: () => { 93 | delete state[id]; 94 | // Also remove from availableHooks and hookArgs to stop rendering HookExecutor 95 | delete argSubscriptions[id]; 96 | setAvailableHooks((prev) => { 97 | const { [id]: removed, ...rest } = prev; 98 | return rest; 99 | }); 100 | }, 101 | }; 102 | }, 103 | [setAvailableHooks], 104 | ); 105 | 106 | const updateState = useCallback((id: string, value: any) => { 107 | const entry = state[id]; 108 | if (!entry) { 109 | return; 110 | } 111 | 112 | // Merge with existing value 113 | entry.value = { ...entry.value, ...value }; 114 | entry.notify(); 115 | }, []); 116 | 117 | const getState = useCallback((id: string) => { 118 | return state[id]?.value; 119 | }, []); 120 | 121 | const registerHook = useCallback( 122 | (id: string, hookFunction: (...args: any[]) => any) => { 123 | setAvailableHooks((prev) => ({ 124 | ...prev, 125 | [id]: hookFunction, 126 | })); 127 | }, 128 | [setAvailableHooks], 129 | ); 130 | 131 | const updateArgs = useCallback( 132 | (id: string, args: any[]) => { 133 | if (!argSubscriptions[id]) { 134 | argSubscriptions[id] = { id, args, argNotifiers: new Set() }; 135 | } else { 136 | argSubscriptions[id].args = args; 137 | } 138 | 139 | // Notify all arg subscribers for this ID 140 | argSubscriptions[id].argNotifiers.forEach((callback) => { 141 | try { 142 | callback(args); 143 | } catch (err) { 144 | console.error('Error in arg subscriber callback:', err); 145 | } 146 | }); 147 | }, 148 | [argSubscriptions], 149 | ); 150 | 151 | const subscribeToArgs = useCallback( 152 | (id: string, callback: (args: any[]) => void) => { 153 | if (!argSubscriptions[id]) { 154 | argSubscriptions[id] = { id, args: [], argNotifiers: new Set() }; 155 | } 156 | 157 | argSubscriptions[id].argNotifiers.add(callback); 158 | 159 | // Always call immediately with current args (even if empty) 160 | callback(argSubscriptions[id].args); 161 | 162 | return () => { 163 | argSubscriptions[id]?.argNotifiers.delete(callback); 164 | }; 165 | }, 166 | [argSubscriptions], 167 | ); 168 | 169 | const contextValue = useMemo( 170 | () => ({ subscribe, updateState, getState, registerHook, updateArgs, subscribeToArgs }), 171 | [subscribe, updateState, getState, registerHook, updateArgs, subscribeToArgs], 172 | ); 173 | 174 | return ( 175 | 176 | {/* Render fake components to execute hooks */} 177 | {Object.keys(availableHooks).map((id) => { 178 | const hookFunction = availableHooks[id]; 179 | // Only render if we have both the hook function and the state entry 180 | if (!hookFunction || !state[id]) { 181 | return null; 182 | } 183 | // Get the initial args for this hook 184 | const initialArgs = argSubscriptions[id]?.args || []; 185 | return ; 186 | })} 187 | {children} 188 | 189 | ); 190 | }; 191 | --------------------------------------------------------------------------------