",
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) => ;
13 |
14 | function RootLayout() {
15 | return (
16 |
17 |
18 |
19 | `1px solid ${theme.palette.divider}` }}>
20 |
21 |
22 | Home
23 |
24 |
50 |
51 |
52 |
53 |
54 |
55 |
56 | );
57 | }
58 |
59 | export default RootLayout;
60 |
--------------------------------------------------------------------------------
/examples/test-app/src/modules/SDKComponent.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import Box from '@mui/material/Box';
4 | import List from '@mui/material/List';
5 | import ListItem from '@mui/material/ListItem';
6 | import ListItemButton from '@mui/material/ListItemButton';
7 | import ListItemIcon from '@mui/material/ListItemIcon';
8 | import ListItemText from '@mui/material/ListItemText';
9 | import Divider from '@mui/material/Divider';
10 | import Slider from '@mui/material/Slider';
11 | import InboxIcon from '@mui/icons-material/Inbox';
12 | import DraftsIcon from '@mui/icons-material/Drafts';
13 | import Checkbox from '@mui/material/Checkbox';
14 |
15 | function valuetext(value: number) {
16 | return `${value}°C`;
17 | }
18 |
19 | interface namedProps {
20 | name: string;
21 | }
22 |
23 | export const NamedSDKComponent = () => {
24 | return (
25 |
26 |
27 |
28 | );
29 | };
30 |
31 | export const PluginSDKComponent = (props: namedProps = { name: 'named' }) => {
32 | return (
33 |
34 |
35 |
36 | );
37 | };
38 |
39 | const SDKComponent = () => {
40 | return (
41 |
42 |
62 |
63 |
77 |
78 | );
79 | };
80 |
81 | export default SDKComponent;
82 |
--------------------------------------------------------------------------------
/federation-cdn-mock/src/modules/SDKComponent.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import Box from '@mui/material/Box';
4 | import List from '@mui/material/List';
5 | import ListItem from '@mui/material/ListItem';
6 | import ListItemButton from '@mui/material/ListItemButton';
7 | import ListItemIcon from '@mui/material/ListItemIcon';
8 | import ListItemText from '@mui/material/ListItemText';
9 | import Divider from '@mui/material/Divider';
10 | import Slider from '@mui/material/Slider';
11 | import Checkbox from '@mui/material/Checkbox';
12 | import InboxIcon from '@mui/icons-material/Inbox';
13 | import DraftsIcon from '@mui/icons-material/Drafts';
14 |
15 | interface namedProps {
16 | name: string;
17 | }
18 |
19 | function valuetext(value: number) {
20 | return `${value}°C`;
21 | }
22 |
23 | export const NamedSDKComponent = () => {
24 | return (
25 |
26 |
27 |
28 | );
29 | };
30 |
31 | export const PluginSDKComponent = (props: namedProps = { name: 'named' }) => {
32 | return (
33 |
34 |
35 |
36 | );
37 | };
38 |
39 | const SDKComponent = () => {
40 | return (
41 |
42 |
62 |
63 |
77 |
78 | );
79 | };
80 |
81 | export default SDKComponent;
82 |
--------------------------------------------------------------------------------
/packages/core/src/createSharedStore.ts:
--------------------------------------------------------------------------------
1 | export type SharedStoreConfig = {
2 | initialState: S;
3 | events: E;
4 | onEventChange: (prevState: S, event: E[number], payload?: any) => S;
5 | };
6 |
7 | const ALL_EVENTS = '*';
8 |
9 | function validateConfig(config: SharedStoreConfig) {
10 | if (typeof config.initialState === 'undefined') {
11 | throw new Error('Initial state must be provided to create a shared store');
12 | }
13 | if (!config.events) {
14 | throw new Error('Events must be provided to create a shared store');
15 | }
16 | if (!config.onEventChange) {
17 | throw new Error('onEventChange callback must be provided to create a shared store');
18 | }
19 |
20 | if (config.events.length === 0) {
21 | throw new Error('At least one event must be defined to create a shared store');
22 | }
23 |
24 | if (config.events.includes(ALL_EVENTS)) {
25 | throw new Error(`Event name "${ALL_EVENTS}" is reserved and cannot be used as an event name`);
26 | }
27 |
28 | const invalidEvent = config.events.find((event) => typeof event !== 'string');
29 | if (invalidEvent) {
30 | throw new Error(`Event names must be of type string, received ${typeof invalidEvent}: "${invalidEvent}"`);
31 | }
32 | }
33 |
34 | export function createSharedStore(config: SharedStoreConfig) {
35 | validateConfig(config);
36 | let state: S = config.initialState;
37 | const subs: { [event in E[number] | typeof ALL_EVENTS]?: Set<() => void> } = {
38 | [ALL_EVENTS]: new Set(),
39 | };
40 |
41 | const getState = () => state;
42 |
43 | function subscribe(event: E[number], callback: () => void) {
44 | if (!subs[event]) {
45 | subs[event] = new Set();
46 | }
47 | subs[event].add(callback);
48 | return () => {
49 | if (subs[event]?.has(callback)) {
50 | subs[event].delete(callback);
51 | }
52 | };
53 | }
54 |
55 | function notify(event: E[number]) {
56 | if (subs[event]) {
57 | subs[event].forEach((cb) => cb());
58 | }
59 | }
60 |
61 | function notifyAll() {
62 | if (subs[ALL_EVENTS]) {
63 | subs[ALL_EVENTS].forEach((cb) => cb());
64 | }
65 | }
66 |
67 | function updateState(event: E[number], payload?: any) {
68 | state = config.onEventChange(state, event, payload);
69 | notify(event);
70 | notifyAll();
71 | }
72 |
73 | function subscribeAll(callback: () => void) {
74 | if (!subs[ALL_EVENTS]) {
75 | subs[ALL_EVENTS] = new Set();
76 | }
77 | subs[ALL_EVENTS].add(callback);
78 | return () => {
79 | if (subs[ALL_EVENTS]?.has(callback)) {
80 | subs[ALL_EVENTS].delete(callback);
81 | }
82 | };
83 | }
84 |
85 | return {
86 | getState,
87 | updateState,
88 | subscribe,
89 | subscribeAll,
90 | };
91 | }
92 |
--------------------------------------------------------------------------------
/examples/test-app/project.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "test-app",
3 | "$schema": "../../node_modules/nx/schemas/project-schema.json",
4 | "sourceRoot": "examples/test-app/src",
5 | "projectType": "application",
6 | "tags": [],
7 | "targets": {
8 | "build": {
9 | "executor": "@nx/webpack:webpack",
10 | "outputs": ["{options.outputPath}"],
11 | "defaultConfiguration": "production",
12 | "options": {
13 | "compiler": "tsc",
14 | "outputPath": "dist/examples/test-app",
15 | "index": "examples/test-app/src/index.html",
16 | "baseHref": "/",
17 | "main": "examples/test-app/src/main.ts",
18 | "tsConfig": "examples/test-app/tsconfig.app.json",
19 | "assets": ["examples/test-app/src/favicon.ico", "examples/test-app/src/assets"],
20 | "styles": [],
21 | "scripts": [],
22 | "webpackConfig": "examples/test-app/webpack.config.ts"
23 | },
24 | "configurations": {
25 | "development": {
26 | "extractLicenses": false,
27 | "optimization": false,
28 | "sourceMap": true
29 | },
30 | "production": {
31 | "fileReplacements": [
32 | {
33 | "replace": "examples/test-app/src/environments/environment.ts",
34 | "with": "examples/test-app/src/environments/environment.prod.ts"
35 | }
36 | ],
37 | "optimization": true,
38 | "outputHashing": "all",
39 | "sourceMap": false,
40 | "namedChunks": false,
41 | "extractLicenses": true,
42 | "vendorChunk": false,
43 | "webpackConfig": "examples/test-app/webpack.config.prod.ts"
44 | }
45 | }
46 | },
47 | "serve": {
48 | "executor": "@nx/webpack:dev-server",
49 | "defaultConfiguration": "development",
50 | "options": {
51 | "buildTarget": "test-app:build",
52 | "hmr": true,
53 | "port": 4200,
54 | "webpackConfig": "examples/test-app/webpack.config.ts"
55 | },
56 | "configurations": {
57 | "development": {
58 | "buildTarget": "test-app:build:development"
59 | },
60 | "production": {
61 | "buildTarget": "test-app:build:production",
62 | "hmr": false
63 | }
64 | }
65 | },
66 | "lint": {
67 | "executor": "@nx/eslint:lint",
68 | "outputs": ["{options.outputFile}"],
69 | "options": {
70 | "lintFilePatterns": ["examples/test-app/**/*.{ts,tsx,js,jsx}"]
71 | }
72 | },
73 | "serve-static": {
74 | "executor": "@nx/web:file-server",
75 | "defaultConfiguration": "production",
76 | "options": {
77 | "buildTarget": "test-app:build",
78 | "watch": false,
79 | "port": 4200
80 | },
81 | "configurations": {
82 | "development": {
83 | "buildTarget": "test-app:build:development"
84 | },
85 | "production": {
86 | "buildTarget": "test-app:build:production"
87 | }
88 | }
89 | },
90 | "test": {
91 | "executor": "@nx/jest:jest",
92 | "outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
93 | "options": {
94 | "jestConfig": "examples/test-app/jest.config.ts"
95 | }
96 | }
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | pull_request:
7 | branches: [main]
8 |
9 | jobs:
10 | install:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v4
14 | with:
15 | fetch-depth: 0
16 | - uses: nrwl/nx-set-shas@v4
17 | # cache node modules for all jobs to use
18 | - uses: './.github/actions/node-cache'
19 | # cache cypress runner
20 | - uses: './.github/actions/cypress-cache'
21 | - name: Install deps
22 | run: npm ci
23 | commitlint:
24 | needs: [install]
25 | runs-on: ubuntu-latest
26 | steps:
27 | - uses: actions/checkout@v4
28 | with:
29 | fetch-depth: 0
30 | - name: Install commitlint
31 | run: |
32 | npm install conventional-changelog-conventionalcommits
33 | npm install commitlint@latest
34 |
35 | - name: Validate current commit (last commit) with commitlint
36 | if: github.event_name == 'push'
37 | run: npx commitlint --last --verbose
38 |
39 | - name: Validate PR commits with commitlint
40 | if: github.event_name == 'pull_request'
41 | run: npx commitlint --from ${{ github.event.pull_request.head.sha }}~${{ github.event.pull_request.commits }} --to ${{ github.event.pull_request.head.sha }} --verbose
42 | lint:
43 | needs: [install]
44 | runs-on: ubuntu-latest
45 | steps:
46 | - uses: actions/checkout@v4
47 | with:
48 | fetch-depth: 0
49 | - uses: './.github/actions/lint'
50 | test:
51 | runs-on: ubuntu-latest
52 | needs: [install]
53 | steps:
54 | - uses: actions/checkout@v4
55 | with:
56 | fetch-depth: 0
57 | - uses: './.github/actions/test-unit'
58 | test-component:
59 | runs-on: ubuntu-latest
60 | needs: [install]
61 | steps:
62 | - uses: actions/checkout@v4
63 | with:
64 | fetch-depth: 0
65 | - uses: './.github/actions/test-component'
66 | build:
67 | runs-on: ubuntu-latest
68 | needs: [install]
69 | steps:
70 | - uses: actions/checkout@v4
71 | with:
72 | fetch-depth: 0
73 | - uses: nrwl/nx-set-shas@v4
74 | - uses: './.github/actions/node-cache'
75 | - name: Install deps
76 | run: npm i
77 | - name: Build affected
78 | run: npx nx affected -t build
79 | test-e2e:
80 | runs-on: ubuntu-latest
81 | needs: [install, build]
82 | steps:
83 | - uses: actions/checkout@v4
84 | with:
85 | fetch-depth: 0
86 | - uses: './.github/actions/test-e2e'
87 | release:
88 | runs-on: ubuntu-latest
89 | needs: [install, lint, test, build, test-e2e, test-component]
90 | if: github.event_name != 'pull_request'
91 | steps:
92 | - uses: actions/checkout@v4
93 | with:
94 | fetch-depth: 0
95 | ssh-key: ${{ secrets.BOTH_AUTH_KEY }}
96 | - uses: './.github/actions/release'
97 | with:
98 | gh_token: ${{ secrets.GITHUB_TOKEN }}
99 | npm_token: ${{ secrets.NPM_TOKEN }}
100 | gh_name: ${{ secrets.GH_NAME }}
101 | gh_email: ${{ secrets.GH_EMAIL }}
102 |
103 |
--------------------------------------------------------------------------------
/nx.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "./node_modules/nx/schemas/nx-schema.json",
3 | "targetDefaults": {
4 | "build": {
5 | "cache": true,
6 | "dependsOn": ["^build"],
7 | "inputs": ["production", "^production"]
8 | },
9 | "lint": {
10 | "cache": true,
11 | "inputs": ["default", "{workspaceRoot}/.eslintrc.json", "{workspaceRoot}/.eslintignore", "{workspaceRoot}/eslint.config.js"]
12 | },
13 | "e2e": {
14 | "cache": true,
15 | "inputs": ["default", "^production"]
16 | },
17 | "@nx/jest:jest": {
18 | "cache": true,
19 | "inputs": ["default", "^production", "{workspaceRoot}/jest.preset.js"],
20 | "options": {
21 | "passWithNoTests": true
22 | },
23 | "configurations": {
24 | "ci": {
25 | "ci": true,
26 | "codeCoverage": true
27 | }
28 | }
29 | },
30 | "component-test": {
31 | "cache": true,
32 | "inputs": ["default", "^production"]
33 | }
34 | },
35 | "namedInputs": {
36 | "default": ["{projectRoot}/**/*", "sharedGlobals"],
37 | "production": [
38 | "default",
39 | "!{projectRoot}/.eslintrc.json",
40 | "!{projectRoot}/eslint.config.js",
41 | "!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)",
42 | "!{projectRoot}/tsconfig.spec.json",
43 | "!{projectRoot}/jest.config.[jt]s",
44 | "!{projectRoot}/src/test-setup.[jt]s",
45 | "!{projectRoot}/test-setup.[jt]s",
46 | "!{projectRoot}/cypress/**/*",
47 | "!{projectRoot}/**/*.cy.[jt]s?(x)",
48 | "!{projectRoot}/cypress.config.[jt]s"
49 | ],
50 | "sharedGlobals": []
51 | },
52 | "nxCloudAccessToken": "MmZlOGJhYmItZTg3OC00MTYzLTkzOWItNTM2YTNjYjE1Yjk2fHJlYWQtd3JpdGU=",
53 | "generators": {
54 | "@nx/react": {
55 | "application": {
56 | "style": "@emotion/styled",
57 | "linter": "eslint",
58 | "bundler": "webpack",
59 | "babel": true
60 | },
61 | "component": {
62 | "style": "@emotion/styled"
63 | },
64 | "library": {
65 | "style": "@emotion/styled",
66 | "linter": "eslint"
67 | }
68 | }
69 | },
70 | "release": {
71 | "projects": ["@scalprum/core", "@scalprum/react-core", "@scalprum/react-test-utils"],
72 | "projectsRelationship": "independent",
73 | "version": {
74 | "git": {
75 | "commit": true,
76 | "tag": true,
77 | "push": true,
78 | "commitMessage": "chore: bump {projectName} to {version} [skip ci]"
79 | },
80 | "preVersionCommand": "npx nx run-many -t build",
81 | "conventionalCommits": true,
82 | "preserveLocalDependencyProtocols": false,
83 | "versionActionsOptions": {
84 | "skipLockFileUpdate": false
85 | }
86 | },
87 | "changelog": {
88 | "git": {
89 | "commit": false,
90 | "tag": true,
91 | "push": true,
92 | "commitMessage": "chore: {projectName} changelog {version} [skip ci]"
93 | },
94 | "projectChangelogs": {
95 | "createRelease": "github",
96 | "renderOptions": {
97 | "authors": true,
98 | "commitReferences": true,
99 | "versionTitleDate": true
100 | }
101 | }
102 | },
103 | "releaseTagPattern": "{projectName}-{version}"
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/examples/test-app/src/entry.tsx:
--------------------------------------------------------------------------------
1 | import React, { useMemo } from 'react';
2 | import { ScalprumProvider } from '@scalprum/react-core';
3 | import { BrowserRouter, Route, Routes } from 'react-router-dom';
4 | import RuntimeErrorRoute from './routes/RuntimeErrorRoute';
5 | import LegacyModules from './routes/LegacyModules';
6 | import RootLayout from './layouts/RootLayout';
7 | import RootRoute from './routes/RootRoute';
8 | import SDKModules from './routes/SDKModules';
9 | import NotFoundError from './routes/NotFoundError';
10 | import { AppsConfig } from '@scalprum/core';
11 | import UseModuleLoading from './routes/UseModuleLoading';
12 | import ApiUpdates from './routes/ApiUpdates';
13 | import RemoteHooks from './routes/RemoteHooks';
14 | import RemoteHookManager from './routes/RemoteHookManager';
15 | import SharedStore from './routes/SharedStore';
16 |
17 | const config: AppsConfig<{ assetsHost?: string }> = {
18 | notFound: {
19 | name: 'notFound',
20 | manifestLocation: '/assets/testPath/foo/bar/nonsense.json',
21 | assetsHost: 'http://127.0.0.1:8001',
22 | },
23 | 'sdk-plugin': {
24 | name: 'sdk-plugin',
25 | assetsHost: 'http://127.0.0.1:8001',
26 | manifestLocation: 'http://127.0.0.1:8001/plugin-manifest.json',
27 | },
28 | 'full-manifest': {
29 | name: 'full-manifest',
30 | assetsHost: 'http://127.0.0.1:8001',
31 | pluginManifest: {
32 | name: 'full-manifest',
33 | version: '1.0.0',
34 | extensions: [],
35 | registrationMethod: 'callback',
36 | baseURL: 'auto',
37 | loadScripts: ['full-manifest.js'],
38 | },
39 | },
40 | };
41 |
42 | const Entry = () => {
43 | const [isBeta, setIsBeta] = React.useState(false);
44 | const chromeApi = useMemo(
45 | () => ({
46 | foo: 'bar',
47 | isBeta: () => isBeta,
48 | setIsBeta,
49 | }),
50 | [isBeta, setIsBeta],
51 | );
52 | return (
53 | `${host}/${script}`),
62 | };
63 | }
64 | return {
65 | ...manifest,
66 | loadScripts: manifest.loadScripts.map((script) => `${script}`),
67 | };
68 | },
69 | },
70 | }}
71 | api={{
72 | chrome: chromeApi,
73 | }}
74 | config={config}
75 | >
76 |
77 |
78 | }>
79 | } />
80 | } />
81 | } />
82 | } />
83 | } />
84 | } />
85 | } />
86 | } />
87 | } />
88 | } />
89 |
90 |
91 |
92 |
93 | );
94 | };
95 |
96 | export default Entry;
97 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@scalprum/source",
3 | "version": "1.0.6",
4 | "license": "MIT",
5 | "workspaces": [
6 | "packages/*"
7 | ],
8 | "scripts": {
9 | "nx": "nx",
10 | "prepare": "husky install",
11 | "dev": "node dev-script.js",
12 | "test:unit": "nx run-many -t test",
13 | "test:unit:affected": "nx affected -t test",
14 | "test:e2e": "node e2e-script.js",
15 | "test:component": "nx run-many -t component-test",
16 | "test": "npm-run-all --parallel test:*",
17 | "build": "nx run-many -t build",
18 | "lint": "nx run-many -t lint",
19 | "postinstall": "rm -rf .webpack-cache"
20 | },
21 | "private": true,
22 | "dependencies": {
23 | "@emotion/react": "11.11.1",
24 | "@emotion/styled": "11.11.0",
25 | "@mui/icons-material": "^5.14.19",
26 | "@mui/material": "^5.14.19",
27 | "@nx/devkit": "21.6.3",
28 | "@openshift/dynamic-plugin-sdk-webpack": "^4.1.0",
29 | "@swc/helpers": "~0.5.2",
30 | "concurrently": "^8.2.2",
31 | "eslint-plugin-prettier": "^5.0.1",
32 | "react": "18.3.1",
33 | "react-dom": "18.3.1",
34 | "react-grid-layout": "^1.4.4",
35 | "react-router-dom": "6.11.2",
36 | "tslib": "^2.3.0"
37 | },
38 | "devDependencies": {
39 | "@babel/core": "^7.14.5",
40 | "@babel/preset-react": "^7.14.5",
41 | "@commitlint/cli": "^19.8.0",
42 | "@commitlint/config-conventional": "^19.8.0",
43 | "@cypress/webpack-dev-server": "4.1.1",
44 | "@emotion/babel-plugin": "11.11.0",
45 | "@module-federation/enhanced": "^0.10.0",
46 | "@nx/cypress": "21.6.3",
47 | "@nx/eslint": "21.6.3",
48 | "@nx/eslint-plugin": "21.6.3",
49 | "@nx/jest": "21.6.3",
50 | "@nx/js": "21.6.3",
51 | "@nx/plugin": "21.6.3",
52 | "@nx/react": "21.6.3",
53 | "@nx/web": "21.6.3",
54 | "@nx/webpack": "21.6.3",
55 | "@nx/workspace": "21.6.3",
56 | "@pmmmwh/react-refresh-webpack-plugin": "^0.5.7",
57 | "@svgr/webpack": "^8.0.1",
58 | "@swc-node/register": "^1.10.10",
59 | "@swc/cli": "0.6.0",
60 | "@swc/core": "^1.11.11",
61 | "@testing-library/react": "14.0.0",
62 | "@types/jest": "30.0.0",
63 | "@types/node": "20.19.0",
64 | "@types/react": "18.3.18",
65 | "@types/react-dom": "18.3.5",
66 | "@types/react-grid-layout": "^1.3.5",
67 | "@typescript-eslint/eslint-plugin": "^6.9.1",
68 | "@typescript-eslint/parser": "^6.9.1",
69 | "babel-jest": "30.0.5",
70 | "cypress": "14.5.4",
71 | "eslint": "~8.46.0",
72 | "eslint-config-prettier": "10.1.8",
73 | "eslint-plugin-cypress": "^2.13.4",
74 | "eslint-plugin-import": "2.27.5",
75 | "eslint-plugin-jsx-a11y": "6.7.1",
76 | "eslint-plugin-react": "7.32.2",
77 | "eslint-plugin-react-hooks": "4.6.0",
78 | "html-webpack-plugin": "^5.5.0",
79 | "husky": "^8.0.3",
80 | "jest": "30.0.5",
81 | "jest-environment-jsdom": "30.0.5",
82 | "jest-environment-node": "^29.4.1",
83 | "npm-run-all": "^4.1.5",
84 | "nx": "21.6.3",
85 | "prettier": "^3.1.0",
86 | "react-refresh": "^0.10.0",
87 | "swc-loader": "^0.2.3",
88 | "ts-jest": "29.4.4",
89 | "ts-node": "10.9.1",
90 | "tsconfig-paths": "^4.2.0",
91 | "typescript": "~5.9.3",
92 | "url-loader": "^4.1.1",
93 | "verdaccio": "^6.0.5",
94 | "wait-on": "^7.2.0",
95 | "webpack-cli": "^5.1.4",
96 | "webpack-merge": "^5.10.0",
97 | "zod": "^3.22.4",
98 | "jest-util": "30.0.5"
99 | },
100 | "nx": {
101 | "includedScripts": []
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/packages/react-core/src/use-remote-hook-manager.ts:
--------------------------------------------------------------------------------
1 | import { getModule } from '@scalprum/core';
2 | import { useContext, useMemo, useReducer, useEffect, useRef, useCallback } from 'react';
3 | import { RemoteHookContext } from './remote-hook-provider';
4 | import { HookConfig, UseRemoteHookResult, HookHandle, RemoteHookManager } from './remote-hooks-types';
5 |
6 | // Note: Removed helper functions and inlined logic to avoid stale closure issues
7 |
8 | // Hook that returns a manager object
9 | export function useRemoteHookManager(): RemoteHookManager {
10 | const context = useContext(RemoteHookContext);
11 | const { subscribe, updateState, getState, registerHook, updateArgs } = context;
12 | const [update, forceUpdate] = useReducer((x) => x + 1, 0);
13 | const subscriptions = useRef void }>>([]);
14 |
15 | const addHook = useCallback((config: HookConfig): HookHandle => {
16 | // Follow useRemoteHook pattern: immediate subscription
17 | const { id, unsubscribe } = subscribe(forceUpdate);
18 |
19 | // Track mounted state for this specific hook
20 | let isMounted = true;
21 |
22 | // Set initial loading state immediately
23 | updateState(id, { loading: true, error: null });
24 | updateArgs(id, config.args || []);
25 |
26 | // Load hook asynchronously (like useRemoteHook)
27 | const loadHook = async () => {
28 | try {
29 | const hookFunction = await getModule(config.scope, config.module, config.importName);
30 |
31 | // Only update if both hook and manager are still mounted
32 | if (isMounted) {
33 | updateState(id, { loading: false, error: null });
34 | updateArgs(id, config.args || []); // Set args before registering hook
35 | registerHook(id, hookFunction);
36 | }
37 | } catch (error) {
38 | if (isMounted) {
39 | console.error('Error loading hook:', error);
40 | updateState(id, { loading: false, error });
41 | }
42 | }
43 | };
44 |
45 | loadHook();
46 |
47 | // Store subscription for cleanup
48 | const subscription = { id, unsubscribe };
49 | subscriptions.current.push(subscription);
50 |
51 | // Return handle with fresh closures
52 | return {
53 | remove() {
54 | isMounted = false; // Mark as unmounted like useRemoteHook
55 | unsubscribe();
56 | // Remove from tracking array
57 | const index = subscriptions.current.indexOf(subscription);
58 | if (index !== -1) {
59 | subscriptions.current.splice(index, 1);
60 | }
61 | },
62 |
63 | updateArgs(args: any[]) {
64 | console.log('Updating args for hook ID:', id, args, { isMounted });
65 | if (isMounted) {
66 | updateArgs(id, args);
67 | }
68 | },
69 | };
70 | }, []);
71 |
72 | const cleanup = useCallback(() => {
73 | // Clean up all subscriptions
74 | subscriptions.current.forEach(({ unsubscribe }) => {
75 | unsubscribe();
76 | });
77 | subscriptions.current.length = 0;
78 | forceUpdate();
79 | }, []);
80 |
81 | const hookResults = useMemo(() => {
82 | const results = subscriptions.current.map(({ id }) => {
83 | const state = getState(id) || { loading: true, error: null };
84 | return {
85 | id,
86 | loading: state.loading,
87 | error: state.error,
88 | hookResult: state.hookResult,
89 | } as UseRemoteHookResult;
90 | });
91 | return results;
92 | }, [update]);
93 |
94 | // Cleanup on unmount
95 | useEffect(() => {
96 | return () => cleanup();
97 | }, [cleanup]);
98 |
99 | return {
100 | addHook,
101 | cleanup,
102 | hookResults,
103 | };
104 | }
105 |
--------------------------------------------------------------------------------
/packages/react-core/src/scalprum-provider.tsx:
--------------------------------------------------------------------------------
1 | import React, { PropsWithChildren, useMemo } from 'react';
2 | import { initialize, AppsConfig, Scalprum } from '@scalprum/core';
3 | import { ScalprumContext } from './scalprum-context';
4 | import { FeatureFlags, PluginLoaderOptions, PluginManifest, PluginStoreOptions, PluginStoreProvider } from '@openshift/dynamic-plugin-sdk';
5 | import { RemoteHookProvider } from './remote-hook-provider';
6 |
7 | /**
8 | * @deprecated
9 | */
10 | export type ScalprumFeed = AppsConfig;
11 |
12 | export type ScalprumProviderInstanceProps = Record> = PropsWithChildren<{
13 | scalprum: Scalprum;
14 | }>;
15 |
16 | function isInstanceProps>(props: ScalprumProviderProps): props is ScalprumProviderInstanceProps {
17 | return 'scalprum' in props;
18 | }
19 |
20 | export type ScalprumProviderConfigurableProps = Record> = PropsWithChildren<{
21 | config: AppsConfig;
22 | api?: T;
23 | children?: React.ReactNode;
24 | pluginSDKOptions?: {
25 | pluginStoreFeatureFlags?: FeatureFlags;
26 | pluginLoaderOptions?: PluginLoaderOptions & {
27 | /** @deprecated */
28 | postProcessManifest?: PluginLoaderOptions['transformPluginManifest'];
29 | };
30 | pluginStoreOptions?: PluginStoreOptions;
31 | };
32 | }>;
33 |
34 | export type ScalprumProviderProps = Record> =
35 | | ScalprumProviderInstanceProps
36 | | ScalprumProviderConfigurableProps;
37 |
38 | function baseTransformPluginManifest(manifest: PluginManifest): PluginManifest {
39 | return { ...manifest, loadScripts: manifest.loadScripts.map((script) => `${manifest.baseURL}${script}`) };
40 | }
41 |
42 | export function ScalprumProvider = Record>(
43 | props: ScalprumProviderProps,
44 | ): React.ReactElement | React.ReactElement {
45 | const state: Scalprum = useMemo(() => {
46 | if (isInstanceProps(props)) {
47 | return props.scalprum;
48 | }
49 |
50 | const { config, api, pluginSDKOptions } = props;
51 | const { postProcessManifest, transformPluginManifest } = pluginSDKOptions?.pluginLoaderOptions || {};
52 | // SDK v4 and v5 compatibility layer
53 | const internalTransformPluginManifest: PluginLoaderOptions['transformPluginManifest'] =
54 | (postProcessManifest || transformPluginManifest) ?? baseTransformPluginManifest;
55 |
56 | if (postProcessManifest) {
57 | console.error(
58 | `[Scalprum] Deprecation warning!
59 | Please use pluginSDKOptions.pluginLoaderOptions.transformPluginManifest instead of pluginSDKOptions.pluginLoaderOptions.postProcessManifest.
60 | The postProcessManifest option will be removed in the next major release.`,
61 | );
62 | }
63 | return initialize({
64 | appsConfig: config,
65 | api,
66 | ...pluginSDKOptions,
67 | pluginLoaderOptions: {
68 | ...pluginSDKOptions?.pluginLoaderOptions,
69 | transformPluginManifest: internalTransformPluginManifest,
70 | },
71 | });
72 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
73 | // @ts-ignore
74 | }, [props.api, props.config, props.scalprum]);
75 |
76 | const value = useMemo(
77 | () => ({
78 | config: state.appsConfig,
79 | api: state.api,
80 | initialized: true,
81 | pluginStore: state.pluginStore,
82 | }),
83 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
84 | // @ts-ignore
85 | [props.api, props.config, props.scalprum, state],
86 | );
87 |
88 | return (
89 |
90 |
91 | {props.children}
92 |
93 |
94 | );
95 | }
96 |
--------------------------------------------------------------------------------
/packages/build-utils/src/executors/sync-dependencies/executor.ts:
--------------------------------------------------------------------------------
1 | import { ExecutorContext, createProjectGraphAsync } from '@nx/devkit';
2 | import { join } from 'path';
3 | import { readFileSync, writeFileSync } from 'fs';
4 | import * as semver from 'semver';
5 | import { execSync } from 'child_process';
6 | export interface SyncDependenciesExecutorOptions {
7 | baseBranch?: string;
8 | remote?: string;
9 | }
10 |
11 | const RANGE_REGEX = /^\D/;
12 |
13 | function shouldBumpDependency(metadata: DependencyMetadata, currentRange: string) {
14 | if (!semver.valid(metadata.version)) {
15 | return false;
16 | }
17 |
18 | if (!semver.satisfies(metadata.version, currentRange)) {
19 | return false;
20 | }
21 | return true;
22 | }
23 |
24 | type DependencyMetadata = {
25 | source: string;
26 | target: string;
27 | type: string;
28 | path: string;
29 | version: string;
30 | };
31 |
32 | function commitToPrevious(baseBranch: string, remote: string) {
33 | const diffCommand = `git diff HEAD`;
34 | const addCommand = `git add .`;
35 | const commitToPreviousCommand = `git commit --no-edit -m "chore: [skip ci] sync dependencies"`;
36 | const pushCommand = `git push ${remote} ${baseBranch}`;
37 | // check if there are any changes to be committed
38 | const isDiff = execSync(diffCommand).toString().length > 0;
39 | if (isDiff) {
40 | execSync(addCommand);
41 | execSync(commitToPreviousCommand);
42 | execSync(pushCommand);
43 | }
44 | }
45 |
46 | export default async function syncDependencies(options: SyncDependenciesExecutorOptions, context: ExecutorContext): Promise<{ success: boolean }> {
47 | console.info(`Executing "sync dependencies"...`, options);
48 | const baseBranch = options.baseBranch || 'main';
49 | const remote = options.remote || 'origin';
50 | try {
51 | const projectName = context.projectName;
52 | const currentProjectRoot = context.projectsConfigurations?.projects?.[projectName]?.root;
53 | const root = context.root;
54 | const dependencyGraph = await createProjectGraphAsync();
55 | const projectPackageJsonPath = join(root, currentProjectRoot, 'package.json');
56 | const initialProjectPackageJson = JSON.parse(readFileSync(projectPackageJsonPath).toString());
57 | const projectPackageJson = JSON.parse(readFileSync(projectPackageJsonPath).toString());
58 | const projectDependencies = dependencyGraph.dependencies[projectName]?.filter((d) => !d.target.startsWith('npm:'));
59 | const dependenciesMetadata = projectDependencies.reduce((acc, d) => {
60 | const packageRoot = context.projectsConfigurations?.projects?.[d.target]?.root;
61 | if (packageRoot) {
62 | const packageJson = JSON.parse(readFileSync(join(root, packageRoot, 'package.json')).toString());
63 | acc.push({
64 | ...d,
65 | path: join(root, packageRoot),
66 | version: packageJson.version,
67 | });
68 | }
69 | return acc;
70 | }, []);
71 |
72 | dependenciesMetadata.forEach((d) => {
73 | const currentRange = projectPackageJson.dependencies[d.target];
74 | const shouldBump = shouldBumpDependency(d, currentRange);
75 | if (shouldBump) {
76 | const maxVersion = semver.maxSatisfying([d.version, currentRange], currentRange);
77 | const prefix = currentRange.match(RANGE_REGEX)?.[0] || '';
78 | const newVersion = `${prefix}${maxVersion}`;
79 | projectPackageJson.dependencies[d.target] = newVersion;
80 | }
81 | });
82 |
83 | if (JSON.stringify(initialProjectPackageJson) !== JSON.stringify(projectPackageJson)) {
84 | writeFileSync(projectPackageJsonPath, JSON.stringify(projectPackageJson, null, 2).concat('\n'), { encoding: 'utf-8' });
85 | commitToPrevious(baseBranch, remote);
86 | }
87 | return { success: true };
88 | } catch (error) {
89 | console.error(error);
90 | return { success: false };
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/packages/react-core/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver).
4 |
5 | ## [0.10.0](https://github.com/scalprum/scaffloding/compare/@scalprum/react-core-0.9.5...@scalprum/react-core-0.10.0) (2025-10-03)
6 |
7 |
8 | ### Features
9 |
10 | * **react-core:** add remote hooks interface ([de92047](https://github.com/scalprum/scaffloding/commit/de920476152382f5a4a16dc50cea499191deefa1))
11 |
12 | ## [0.9.5](https://github.com/scalprum/scaffloding/compare/@scalprum/react-core-0.9.4...@scalprum/react-core-0.9.5) (2025-03-31)
13 |
14 | ### Dependency Updates
15 |
16 | * `@scalprum/core` updated to version `0.8.3`
17 | ## [0.9.4](https://github.com/scalprum/scaffloding/compare/@scalprum/react-core-0.9.3...@scalprum/react-core-0.9.4) (2025-03-20)
18 |
19 | ### Dependency Updates
20 |
21 | * `@scalprum/core` updated to version `0.8.2`
22 | ## [0.9.3](https://github.com/scalprum/scaffolding/compare/@scalprum/react-core-0.9.2...@scalprum/react-core-0.9.3) (2024-09-24)
23 |
24 |
25 | ### Bug Fixes
26 |
27 | * **react:** memoize provider value ([a7b95eb](https://github.com/scalprum/scaffolding/commit/a7b95eb0b0ce8cf6e04937fcad54a53681f7188a))
28 |
29 | ## [0.9.2](https://github.com/scalprum/scaffolding/compare/@scalprum/react-core-0.9.1...@scalprum/react-core-0.9.2) (2024-09-24)
30 |
31 | ## [0.9.1](https://github.com/scalprum/scaffolding/compare/@scalprum/react-core-0.9.0...@scalprum/react-core-0.9.1) (2024-09-11)
32 |
33 | ### Dependency Updates
34 |
35 | * `@scalprum/core` updated to version `0.8.1`
36 |
37 | ### Bug Fixes
38 |
39 | * **core:** ensure scalprum instance receives configuration updates ([9caf092](https://github.com/scalprum/scaffolding/commit/9caf092b741300cfd395b42844e21804204a297c))
40 |
41 | ## [0.9.0](https://github.com/scalprum/scaffolding/compare/@scalprum/react-core-0.8.0...@scalprum/react-core-0.9.0) (2024-09-09)
42 |
43 | ### Dependency Updates
44 |
45 | * `@scalprum/core` updated to version `0.8.0`
46 |
47 | ### Features
48 |
49 | * **core:** allow directly using plugin manifest ([9eede15](https://github.com/scalprum/scaffolding/commit/9eede15da2db3113f480326597f612e8cd853840))
50 |
51 | ## [0.8.0](https://github.com/scalprum/scaffolding/compare/@scalprum/react-core-0.7.1...@scalprum/react-core-0.8.0) (2024-05-30)
52 |
53 |
54 | ### Features
55 |
56 | * enable ScalprumProvider intialization with scalprum instance ([894c8bf](https://github.com/scalprum/scaffolding/commit/894c8bf3d9f32a3f2236d8f1fac86a557cd09639))
57 |
58 | ## [0.7.1](https://github.com/scalprum/scaffolding/compare/@scalprum/react-core-0.7.0...@scalprum/react-core-0.7.1) (2024-01-26)
59 |
60 | ## [0.7.0](https://github.com/scalprum/scaffolding/compare/@scalprum/react-core-0.6.7...@scalprum/react-core-0.7.0) (2024-01-22)
61 |
62 | ### Dependency Updates
63 |
64 | * `@scalprum/core` updated to version `0.7.0`
65 |
66 | ### Features
67 |
68 | * support SDK v5 ([aa922b7](https://github.com/scalprum/scaffolding/commit/aa922b710d50c2ae5058a4b11a623c93ce89edcf))
69 |
70 | ## [0.6.7](https://github.com/scalprum/scaffolding/compare/@scalprum/react-core-0.6.6...@scalprum/react-core-0.6.7) (2024-01-22)
71 |
72 | ### Dependency Updates
73 |
74 | * `@scalprum/core` updated to version `0.6.6`
75 | ## [0.6.6](https://github.com/scalprum/scaffolding/compare/@scalprum/react-core-0.6.5...@scalprum/react-core-0.6.6) (2024-01-15)
76 |
77 | ## [0.6.5](https://github.com/scalprum/scaffolding/compare/@scalprum/react-core-0.6.4...@scalprum/react-core-0.6.5) (2023-12-04)
78 |
79 | ### Dependency Updates
80 |
81 | * `@scalprum/core` updated to version `0.6.5`
82 | ## [0.6.4](https://github.com/scalprum/scaffolding/compare/@scalprum/react-core-0.6.3...@scalprum/react-core-0.6.4) (2023-12-04)
83 |
84 | ### Dependency Updates
85 |
86 | * `@scalprum/core` updated to version `0.6.4`
87 | ## [0.6.3](https://github.com/scalprum/scaffolding/compare/@scalprum/react-core-0.6.2...@scalprum/react-core-0.6.3) (2023-12-04)
88 |
89 | ### Dependency Updates
90 |
91 | * `@scalprum/core` updated to version `0.6.3`
92 | # Changelog
93 |
--------------------------------------------------------------------------------
/examples/test-app/src/routes/LegacyModules.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Button, Stack } from '@mui/material';
2 | import { preloadModule } from '@scalprum/core';
3 | import { ScalprumComponent } from '@scalprum/react-core';
4 | import React, { PropsWithChildren, useState } from 'react';
5 | import GridLayout from 'react-grid-layout';
6 | import LoadingComponent from '../components/LoadingComponent';
7 |
8 | import 'react-grid-layout/css/styles.css';
9 | import 'react-resizable/css/styles.css';
10 |
11 | const BoxWrapper: React.FC = ({ children }) => (
12 | {children}
13 | );
14 |
15 | const LegacyModules = () => {
16 | const [showPreLoadedModule, setShowPreLoadedModule] = useState(false);
17 | const [showPreLoadedModuleWPF, setShowPreLoadedModuleWPF] = useState(false);
18 | const [layout, setLayout] = useState([{ isResizable: false, isDraggable: false, i: 'initial', x: 0, y: 0, w: 1, h: 3 }]);
19 |
20 | const toggles = {
21 | preLoad: { value: showPreLoadedModule, toggle: setShowPreLoadedModule },
22 | preFetch: { value: showPreLoadedModuleWPF, toggle: setShowPreLoadedModuleWPF },
23 | };
24 |
25 | const handlePreload = async () => {
26 | try {
27 | await preloadModule('sdk-plugin', './PreLoadedModule');
28 | } catch (error) {
29 | console.log('Unable to preload module: ', error);
30 | }
31 | };
32 |
33 | const handlePreloadPF = async () => {
34 | try {
35 | await preloadModule('sdk-plugin', './ModuleOne');
36 | } catch (error) {
37 | console.log('Unable to preload module: ', error);
38 | }
39 | };
40 |
41 | const handleToggleElement = (name: keyof typeof toggles) => {
42 | const { value, toggle } = toggles[name];
43 | toggle(!value);
44 | if (!value) {
45 | // add new item by id
46 | setLayout((prev) => {
47 | const newItem: GridLayout.Layout = {
48 | i: name,
49 | x: prev.length % 2,
50 | y: Infinity, // put last
51 | w: 1,
52 | h: 3,
53 | isResizable: false,
54 | isDraggable: false,
55 | };
56 | return [...prev, newItem];
57 | });
58 | } else {
59 | setLayout((prev) =>
60 | prev
61 | .filter(({ i }) => i !== name)
62 | .map((item, index) => ({
63 | ...item,
64 | x: index % 2,
65 | y: Infinity, // put last
66 | })),
67 | );
68 | }
69 | };
70 | return (
71 |
72 |
73 |
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 |
--------------------------------------------------------------------------------