├── .gitignore ├── .npmrc ├── .nvmrc ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── assets └── module_container_github.svg ├── docs ├── ANGULAR-LAZY-COMPONENT.md ├── MODULE-REGISTRY.md └── REACT-LAZY-COMPONENT.md ├── package.json ├── pom.xml ├── protractor.conf.js ├── src ├── ReactModuleContainerErrors.js ├── angular-lazy-component.js ├── base-lazy-component.js ├── context.js ├── demo │ ├── EventListener.js │ ├── demo-4.scss │ ├── demo-5.scss │ ├── demo-shared.scss │ ├── demo.js │ ├── demo.scss │ └── module.js ├── index.html ├── index.js ├── lazy │ ├── angular-module.js │ └── react-module.js ├── module-registry.js ├── react-lazy-component.js ├── react-loadable-component.js ├── react-module-container.js └── tag-appender.js ├── test ├── e2e │ └── app.e2e.js ├── mocha-setup.js ├── mock │ ├── SomeComponent.js │ ├── SomeSubComponent.js │ └── fake-server.js ├── module-registry.spec.js ├── react-loadable-component.spec.js └── tag-appender.spec.js └── wallaby.js /.gitignore: -------------------------------------------------------------------------------- 1 | generated 2 | node_modules 3 | .git 4 | .DS_Store 5 | dist 6 | target 7 | coverage 8 | .idea 9 | npm-debug.log 10 | *.iml 11 | test/e2e/screenshots/* 12 | yarn.lock 13 | package-lock.json -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 12 2 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [{ 4 | "name": "Launch Mocha", 5 | "type": "node", 6 | "request": "launch", 7 | "program": "${workspaceRoot}/node_modules/mocha/bin/_mocha", 8 | "stopOnEntry": false, 9 | "args": ["{test,src}/**/*.spec.js", "--no-timeouts"], 10 | "cwd": "${workspaceRoot}", 11 | "preLaunchTask": null, 12 | "runtimeExecutable": null, 13 | "runtimeArgs": [ 14 | "--nolazy", 15 | "--require", "babel-register", 16 | "--require", "${workspaceRoot}/node_modules/yoshi/lib/ignore-extensions.js" 17 | ], 18 | "env": { 19 | "NODE_ENV": "test" 20 | }, 21 | "console": "internalConsole", 22 | "sourceMaps": true, 23 | "outDir": null 24 | }] 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Wix.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![logo](assets/module_container_github.svg) 2 | 3 | ## Why? 4 | It's a small library that allows big things. 5 | Its main purpose is to enable building large-scale application with lazy-loaded modules based on either React or Angular. 6 | 7 | ## Getting started or 4 simple steps to convert to lazy module 8 | ### Step 1: Add `react-module-container` 9 | Add `react-module-container` npm module as your dependency. 10 | ```bash 11 | npm install --save react-module-container 12 | ``` 13 | ### Step 2: Create manifest file and register your component 14 | Create a `manifest.js` file that describe your future lazy module. It can be either [Angular](./docs/ANGULAR-LAZY-COMPONENT.md) or [React](./docs/REACT-LAZY-COMPONENT.md) lazy module. 15 | 16 | ```js 17 | class NgMainApp extends AngularLazyComponent { 18 | constructor(props) { 19 | super(props, { 20 | files: [ 21 | `${props.topology.staticsBaseUrl}angular-client.css`, 22 | `${props.topology.staticsBaseUrl}angular-client.bundle.js` 23 | ], 24 | module: 'myApp', 25 | component: 'main-app', 26 | unloadStylesOnDestroy: true 27 | }); 28 | } 29 | } 30 | 31 | ModuleRegistry.registerComponent('angular.main', () => NgMainApp); 32 | ``` 33 | 34 | ### Step 3: Load the manifest file by hosting application 35 | Load `manifest.js` file in the `index.html` of your hosting application. 36 | ```html 37 | 38 | ``` 39 | 40 | ### Step 4: Instantiate your lazy component 41 | Instantiate your lazy component using `ModuleRegistry` and render it inside hosting application. 42 | 43 | ```js 44 | const AngularComponent = ModuleRegistry.component('angular.main'); 45 | 46 | class App extends React.Component { 47 | render() { 48 | return ( 49 | 52 | ); 53 | } 54 | } 55 | ``` 56 | 57 | ## API 58 | * [`ModuleRegistry`](./docs/MODULE-REGISTRY.md) 59 | * [`ReactLazyComponent`](./docs/REACT-LAZY-COMPONENT.md) 60 | * [`AngularLazyComponent`](./docs/ANGULAR-LAZY-COMPONENT.md) 61 | 62 | ## Demo 63 | * `git clone git@github.com:wix/react-module-container.git` 64 | * `cd react-module-container` 65 | * `npm install` 66 | * `npm start` 67 | * `http://localhost:3200` 68 | 69 | ## License 70 | 71 | The MIT License. 72 | 73 | See [LICENSE](LICENSE) 74 | -------------------------------------------------------------------------------- /assets/module_container_github.svg: -------------------------------------------------------------------------------- 1 | oss -------------------------------------------------------------------------------- /docs/ANGULAR-LAZY-COMPONENT.md: -------------------------------------------------------------------------------- 1 | # Angular Module 2 | You should create a React component for your angular application using `AngularLazyComponent`. 3 | The `AngularLazyComponent` uses the manifest to lazy load dependencies and bootstrap the angular app. 4 | You should register the new react component using `ModuleRegistry.registerComponent()`. 5 | 6 | ```js 7 | class MyNgComp extends AngularLazyComponent { 8 | constructor(props) { 9 | super(props, { 10 | /*see manifest below*/ 11 | }); 12 | } 13 | } 14 | 15 | ModuleRegistry.registerComponent('Prefix.componentName', () => MyNgComp); 16 | ``` 17 | 18 | `props` contains the parameters from the host and will be available within the manifest. 19 | 20 | ## Manifest 21 | 22 | ### Fields: 23 | * `files`: Array of `file entry` and sub arrays of `file entry`. 24 | * `file entry` url string or object of type { url : string, optional: boolean }, when optional set to `true` the component will keep loading even when that resource failed to load, by default `false`. 25 | 26 | Every **url string** in the main array will be **loaded independently**. 27 | Using a **sub array** allows to **serialize** the download of its items. 28 | * `resolve`(optional): A function (`() => Promise`) to fetch data **before** bootstrap and **in parallel** of downloading the `files`. 29 | * `prepare`(optional): A function (`() => Promise || void`) to prepare data **before** bootstrap and **after** all `files` were downloaded and `resolve` resolved. 30 | * `module`: The name of the angular module that will be bootstrapped. 31 | * `component`: The name of your angular application's root directive/component that should be rendered. 32 | * `unloadStylesOnDestroy`(optional, default true): Specifies if loaded stylesheets should be unloaded when component is destroyed. 33 | 34 | ##### Please note 35 | * The `resolve` function must return a `promise`. Common usage for `resolve` would be to fetch data that affects how your app is rendered, like **experiments** or **user privileges**. 36 | * The `prepare` function can return a new promise if asynchronous behaviour is required. 37 | 38 | ### Explanation 39 | Before being rendered, all of the required `files` will be downloaded and `resolve` will executed. 40 | Once all `files` are loaded and `resolve` resolved, the function `prepare` will be executed. 41 | Once `prepare` finished/resolved `angular.bootstrap()` will be called with the component and module you passed. 42 | 43 | ### Example 44 | ```js 45 | { 46 | files: ['y.js', `${props.files.fakeFile}`, ['1.js', '2.js', '3.js'], 'z.js'], 47 | resolve: () => { 48 | return fetchExperiments().then(response => { 49 | return { 50 | experiments: response.data // experiments would be available on the props service 51 | }; 52 | }); 53 | }, 54 | prepare: () => { 55 | // customLogic(); 56 | // or 57 | // return new Promise(...); 58 | }, 59 | module: 'your-module-name' 60 | component: 'your-main-component-name' 61 | } 62 | ``` 63 | ## Accessing parameters 64 | Your angular application can use a service called `props` which contains all the parameters passed from the host. 65 | Once any of the props values are changed, `$digest()` will be called for you. 66 | 67 | ### Example 68 | ```js 69 | class MyCompController { 70 | constructor(props) { 71 | this.value = 'angular-input-value'; 72 | this.props = props; 73 | } 74 | } 75 | 76 | myApp.component('myComp', { 77 | template: 78 | `
79 |
{{$ctrl.props().value}}
80 | 81 |
`, 82 | controller: MyCompController 83 | }); 84 | ``` 85 | This `props` parameter is an angular `services` that can be injects and if called as a function can return `props` object. 86 | See `{{$ctrl.props().value}}` line in the example above. 87 | 88 | 89 | ## Advanced topics 90 | 91 | ### Changing The host's route (switching modules) 92 | Use the `routerLink` directive, example: 93 | `name` 94 | 95 | ### Hosting another component within a hosted component 96 | Use the `moduleRegistry` directive, example: 97 | ```js 98 | 99 | ``` 100 | 101 | ### Lifecycle events 102 | All lazy components fire 3 lifecycle events (Via the ModuleRegistry): 103 | * `reactModuleContainer.componentStartLoading` fires before the scripts are loaded, and before the `prepare` function is called 104 | * `reactModuleContainer.componentReady` fires after the scripts are loaded, the `prepare` promise resolved, and the component is on the stage. 105 | * `reactModuleContainer.componentWillUnmount` fire before the component is removed from the DOM. 106 | -------------------------------------------------------------------------------- /docs/MODULE-REGISTRY.md: -------------------------------------------------------------------------------- 1 | # Module Registry 2 | 3 | Module Registry is the heart of React Module Container. It consists of the following: 4 | 5 | * Components - React or Angular lazy modules that can be registered and loaded at the later stage. 6 | * Events - A simple PubSub listener interface that allows some modules to subscribe to event updates by other modules. 7 | * Methods - A simple RPC interface allowing one module to make a remote call that will be executed by another module. 8 | 9 | ## Components 10 | 11 | ### `registerComponent(uniqueName: string, factoryFn: Function): void` 12 | ```ts 13 | ModuleRegistry.registerComponent('hotels.Dashboard', () => ComponentClass); 14 | ``` 15 | 16 | ### `component(uniqueName: string): Component` 17 | ```ts 18 | const ComponentClass = ModuleRegistry.component('hotels.Dashboard'); 19 | ``` 20 | 21 | ## Events 22 | 23 | ### `addListener(eventName: string, callbackFn: Function): { remove: Function }` 24 | Adding listener return an object that contains `.remove()` method that will unsubscribe from the event. 25 | ```ts 26 | const subscription = ModuleRegistry.addListener('core.SessionUpdate', function (session) { 27 | // consumer handles updated session here 28 | }); 29 | subscription.remove(); 30 | ``` 31 | 32 | ### `notifyListeners(eventName: string): void` 33 | ```ts 34 | ModuleRegistry.notifyListeners('core.SessionUpdate', session); 35 | ``` 36 | 37 | ## Methods 38 | 39 | ### `registerMethod(uniqueName: string, methodGenerator: () => Function): void` 40 | ```ts 41 | ModuleRegistry.registerMethod('inbox.getContactDetails', () => contactService.getContactDetails); 42 | ``` 43 | 44 | ### `invoke(uniqueName: string): any` 45 | ```ts 46 | const contactDetails = await ModuleRegistry.invoke('inbox.getContactDetails', 'johnsmith@example.com'); 47 | ``` 48 | 49 | -------------------------------------------------------------------------------- /docs/REACT-LAZY-COMPONENT.md: -------------------------------------------------------------------------------- 1 | # React Module 2 | 3 | You should register your main react component using `ModuleRegistry.registerComponent()`. 4 | ```js 5 | // ../src/main-component.js: 6 | 7 | const MainComponent = props => ( 8 |
9 | {/*Your application*/} 10 |
11 | ); 12 | 13 | window.ModuleRegistry.registerComponent('appName.mainComponentName', () => MainComponent); 14 | ``` 15 | 16 | You should create a new React component using `ReactLazyComponent` to lazy load your main component and its dependencies. 17 | You should add the path for the script which register your main component to the manifest's `files` array. 18 | You should register the new lazy component using `ModuleRegistry.registerComponent()`. 19 | 20 | ```js 21 | class MainComponentLazyComponent extends ReactLazyComponent { 22 | constructor(props) { 23 | //see manifest explanation below 24 | const manifest = { 25 | files: ['src/main-component.js'], 26 | resolve: () => { /* fetch some data */ }, // optional 27 | component: 'appName.mainComponentName' 28 | }; 29 | super(props, manifest); 30 | } 31 | } 32 | 33 | ModuleRegistry.registerComponent('appName.lazyMainComponentName', () => MainComponentLazyComponent); 34 | ``` 35 | 36 | `props` contains the parameters from the host and will be available within the manifest. 37 | 38 | ## Manifest 39 | ### Fields 40 | * `files`: Array of `file entry` and sub arrays of `file entry`. 41 | * `file entry` url string or object of type { url : string, optional: boolean }, when optional set to `true` the component will keep loading even when that resource failed to load, by default `false`. 42 | 43 | Every **url string** in the main array will be **loaded independently**. 44 | Using a **sub array** allows to **serialize** the download of its items. 45 | * `resolve`(optional): A function (`() => Promise`) which will execute **in parallel of downloading the** `files`. 46 | * `component`: The name you used to register your main react component to the `ModuleRegistry`. 47 | 48 | ##### Please note 49 | * The `resolve` function must return a `promise`. Common usage for `resolve` would be to fetch data that affects how your app is rendered, like **experiments** or **user privileges**. 50 | 51 | ### Example 52 | ```js 53 | { 54 | files: ['y.js', `${props.files.fakeFile}`, ['1.js', '2.js', '3.js'], 'z.js'], 55 | resolve: () => { 56 | return fetchExperiments().then(response => { 57 | return { 58 | experiments: response.data // experiments would be available on the props 59 | }; 60 | }); 61 | }, 62 | component: 'Prefix.mainComponentName' 63 | } 64 | ``` 65 | 66 | ### Explanation 67 | When the host tries to render the lazy component, it starts by downloading all the required `files` and execute `resolve`. 68 | Once all `files` are loaded and `resolve` resolved, the component is rendered and receives the props parameter as `props`. 69 | 70 | ### Lifecycle events 71 | All lazy components fire 3 lifecycle events (Via the ModuleRegistry): 72 | * `reactModuleContainer.componentStartLoading` fires before the scripts are loaded 73 | * `reactModuleContainer.componentReady` fires after the scripts are loaded and the component is on the stage 74 | * `reactModuleContainer.componentWillUnmount` fire before the component is removed from the DOM. 75 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-module-container", 3 | "version": "1.0.1486", 4 | "description": "", 5 | "homepage": "", 6 | "private": false, 7 | "author": { 8 | "name": "Shahar Talmi", 9 | "email": "shahar@wix.com", 10 | "url": "" 11 | }, 12 | "scripts": { 13 | "start": "yoshi start --entry-point=./dist/test/mock/fake-server.js --with-tests", 14 | "build": ":", 15 | "precommit": "yoshi lint", 16 | "pretest": "yoshi build", 17 | "test": "yoshi test --mocha --protractor", 18 | "posttest": "yoshi lint", 19 | "release": "yoshi release" 20 | }, 21 | "files": [ 22 | "dist" 23 | ], 24 | "main": "dist/src/index.js", 25 | "publishConfig": { 26 | "registry": "https://registry.npmjs.org/" 27 | }, 28 | "yoshi": { 29 | "entry": { 30 | "react-module-container": "./react-module-container.js", 31 | "demo": "./demo/demo.js", 32 | "module": "./demo/module.js", 33 | "lazy/angular-module": "./lazy/angular-module.js", 34 | "lazy/react-module": "./lazy/react-module.js", 35 | "demo-4": "./demo/demo-4.scss", 36 | "demo-5": "./demo/demo-5.scss", 37 | "demo-shared": "./demo/demo-shared.scss", 38 | "umd": "./index.js" 39 | }, 40 | "externals": { 41 | "react": "React", 42 | "react-dom": "ReactDOM" 43 | }, 44 | "exports": "reactModuleContainer" 45 | }, 46 | "devDependencies": { 47 | "@testing-library/react": "^12.0.0", 48 | "@wix/yoshi": "^4.0.0", 49 | "babel-plugin-transform-builtin-extend": "^1.1.2", 50 | "chai": "^4.2.0", 51 | "express": "^4.13.4", 52 | "global-jsdom": "^8.1.0", 53 | "jsdom": "^16.7.0", 54 | "jsdom-global": "^3.0.2", 55 | "protractor": "^7.0.0", 56 | "react-redux": "^5.1.2", 57 | "react-router": "^3.2.6", 58 | "redux": "^4.0.5", 59 | "sinon": "^9.2.4", 60 | "sinon-chai": "^3.7.0", 61 | "yoshi-angular-dependencies": "^4.167.0", 62 | "yoshi-style-dependencies": "^4.228.0" 63 | }, 64 | "babel": { 65 | "presets": [ 66 | "yoshi" 67 | ], 68 | "plugins": [ 69 | [ 70 | "babel-plugin-transform-builtin-extend", 71 | { 72 | "globals": [ 73 | "Error", 74 | "Array" 75 | ] 76 | } 77 | ] 78 | ] 79 | }, 80 | "eslintConfig": { 81 | "extends": "yoshi", 82 | "rules": { 83 | "prettier/prettier": "off" 84 | } 85 | }, 86 | "dependencies": { 87 | "lodash": "^4.17.15", 88 | "prop-types": "^15.7.2", 89 | "react": "^16.13.1", 90 | "react-dom": "^16.13.1" 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4.0.0 4 | com.wixpress.fed 5 | react-module-container 6 | pom 7 | react-module-container 8 | react-module-container 9 | 1.0.0-SNAPSHOT 10 | 11 | 12 | Shahar Talmi 13 | shahar@wix.com 14 | 15 | owner 16 | 17 | 18 | 19 | Roy Miloh 20 | roymi@wix.com 21 | 22 | owner 23 | 24 | 25 | 26 | 27 | com.wixpress.common 28 | wix-master-parent 29 | 100.0.0-SNAPSHOT 30 | 31 | 32 | -------------------------------------------------------------------------------- /protractor.conf.js: -------------------------------------------------------------------------------- 1 | process.env.FAKE_SERVER_PORT = 3100; 2 | 3 | module.exports.config = { 4 | baseUrl: `http://localhost:${process.env.FAKE_SERVER_PORT}/`, 5 | 6 | onPrepare() { 7 | browser.ignoreSynchronization = true; 8 | require('./dist/test/mock/fake-server'); 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /src/ReactModuleContainerErrors.js: -------------------------------------------------------------------------------- 1 | export class ReactModuleContainerError extends Error { 2 | constructor(message) { 3 | super(message); 4 | this.name = this.constructor.name; 5 | 6 | if (typeof Error.captureStackTrace === 'function') { 7 | Error.captureStackTrace(this, this.constructor); 8 | } else { 9 | this.stack = (new Error(message)).stack; 10 | } 11 | } 12 | } 13 | 14 | export class UnregisteredMethodInvokedError extends ReactModuleContainerError { 15 | constructor(methodName) { 16 | super(`ModuleRegistry.invoke ${methodName} used but not yet registered`); 17 | this.name = 'UnregisteredMethodInvokedError'; 18 | } 19 | } 20 | 21 | export class UnregisteredComponentUsedError extends ReactModuleContainerError { 22 | constructor(componentId) { 23 | super(`ModuleRegistry.component ${componentId} used but not yet registered`); 24 | this.name = 'UnregisteredComponentUsedError'; 25 | } 26 | } 27 | 28 | export class ListenerCallbackError extends ReactModuleContainerError { 29 | constructor(methodName, error) { 30 | super(`Error in listener callback of module registry method: ${methodName}`); 31 | this.name = 'ListenerCallbackError'; 32 | this.stack = this.stack + error.stack; 33 | this.originalError = error; 34 | } 35 | } 36 | 37 | export class LazyComponentLoadingError extends ReactModuleContainerError { 38 | constructor(component, error) { 39 | super(`Error loading moduleRegistry lazy component ${component}`); 40 | this.name = 'LazyComponentLoadingError'; 41 | this.stack = this.stack + error.stack; 42 | this.originalError = error; 43 | } 44 | } 45 | 46 | export class FileAppenderLoadError extends ReactModuleContainerError { 47 | constructor(fileUrl) { 48 | super(`FilesAppender failed to load file ${fileUrl}`); 49 | this.name = 'FileAppenderLoadError'; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/angular-lazy-component.js: -------------------------------------------------------------------------------- 1 | /* global angular */ 2 | import React from 'react'; 3 | import PropTypes from 'prop-types'; 4 | import {render, unmountComponentAtNode} from 'react-dom'; 5 | import ModuleRegistry from './module-registry'; 6 | import BaseLazyComponent from './base-lazy-component'; 7 | 8 | class AddRouterContext extends React.Component { 9 | getChildContext() { 10 | return {router: this.props.router}; 11 | } 12 | render() { 13 | return this.props.children; 14 | } 15 | } 16 | AddRouterContext.childContextTypes = { 17 | router: PropTypes.any 18 | }; 19 | AddRouterContext.propTypes = { 20 | router: PropTypes.any, 21 | children: PropTypes.any 22 | }; 23 | 24 | class AngularLazyComponent extends BaseLazyComponent { 25 | 26 | componentDidMount() { 27 | this.mounted = true; 28 | this.resourceLoader.then(() => { 29 | if (this.mounted) { 30 | const component = `<${this.manifest.component}>`; 31 | this.$injector = angular.bootstrap(component, [this.manifest.module, ['$provide', '$compileProvider', ($provide, $compileProvider) => { 32 | $provide.factory('props', () => () => this.mergedProps); 33 | $compileProvider.directive('moduleRegistry', () => ({ 34 | scope: {component: '@', props: '<'}, 35 | controller: ['$scope', '$element', ($scope, $element) => { 36 | const Component = ModuleRegistry.component($scope.component); 37 | $scope.$watch(() => $scope.props, () => { 38 | render( 39 | 40 | 41 | , $element[0]); 42 | }, true); 43 | $scope.$on('$destroy', () => unmountComponentAtNode($element[0])); 44 | // super hack to prevent angular from preventing external route changes 45 | $element.on('click', e => e.preventDefault = () => delete e.preventDefault); 46 | }] 47 | })); 48 | $compileProvider.directive('routerLink', () => ({ 49 | transclude: true, 50 | scope: {to: '@'}, 51 | template: '', 52 | controller: ['$scope', $scope => { 53 | $scope.handleClick = event => { 54 | if (event.ctrlKey || event.metaKey || event.shiftKey || event.which === 2 || event.button === 2) { 55 | return; 56 | } else { 57 | this.props.router.push($scope.to); 58 | event.preventDefault(); 59 | } 60 | }; 61 | }] 62 | })); 63 | }]]); 64 | this.node.appendChild(this.$injector.get('$rootElement')[0]); 65 | } 66 | }); 67 | } 68 | 69 | componentWillUnmount() { 70 | this.mounted = false; 71 | if (this.$injector) { 72 | this.$injector.get('$rootScope').$destroy(); 73 | this.$injector = null; 74 | } 75 | super.componentWillUnmount(); 76 | } 77 | 78 | componentDidUpdate() { 79 | if (this.$injector && !this.$injector.get('$rootScope').$$phase) { 80 | this.$injector.get('$rootScope').$digest(); 81 | } 82 | } 83 | 84 | render() { 85 | return
this.node = node}/>; 86 | } 87 | } 88 | AngularLazyComponent.propTypes = { 89 | router: PropTypes.any 90 | }; 91 | 92 | export default AngularLazyComponent; 93 | -------------------------------------------------------------------------------- /src/base-lazy-component.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ModuleRegistry from './module-registry'; 3 | import {filesAppender, unloadStyles} from './tag-appender'; 4 | import assign from 'lodash/assign'; 5 | import {LazyComponentLoadingError} from './ReactModuleContainerErrors'; 6 | import { ReactModuleContainerContext } from './context'; 7 | 8 | export default class BaseLazyComponent extends React.Component { 9 | static contextType = ReactModuleContainerContext; 10 | 11 | constructor(props, manifest) { 12 | super(props); 13 | this.manifest = manifest; 14 | } 15 | 16 | get mergedProps() { 17 | return assign({}, this.props, this.resolvedData); 18 | } 19 | 20 | hasSuspensePayload() { 21 | return !!this.context?.suspensePayload; 22 | } 23 | 24 | setupSuspensePayload() { 25 | if (!this.context?.suspense) { 26 | return; 27 | } 28 | 29 | const suspensePayload = this.context.suspensePayload = {}; 30 | suspensePayload.promise = this.resourceLoader.then(() => { 31 | // Store the resolvedData from the suspended instance to be reloaded in the new component instance 32 | suspensePayload.resolvedData = this.resolvedData; 33 | }); 34 | } 35 | 36 | handleSuspenseRender() { 37 | if (!this.context?.suspense) { 38 | return; 39 | } 40 | 41 | const { suspensePayload } = this.context; 42 | const isResolved = !!suspensePayload.resolvedData; 43 | 44 | if (!isResolved) { 45 | throw suspensePayload.promise; 46 | } 47 | 48 | // Promise is resolved, restore the data from the suspended instance to the instance 49 | if (!this.resolvedData) { 50 | this.resolvedData = suspensePayload.resolvedData; 51 | this.resourceLoader = suspensePayload.promise; 52 | } 53 | } 54 | 55 | UNSAFE_componentWillMount() { 56 | if (this.hasSuspensePayload()) { 57 | // All of this already happened, we just wait for the previous promise to resolve and we'll restore the needed state. 58 | return; 59 | } 60 | 61 | ModuleRegistry.notifyListeners('reactModuleContainer.componentStartLoading', this.manifest.component); 62 | const prepare = this.manifest.prepare ? () => this.manifest.prepare() : () => undefined; 63 | const filesAppenderPromise = filesAppender(this.manifest.files, this.manifest.crossorigin).then(prepare); 64 | const resolvePromise = this.manifest.resolve ? this.manifest.resolve() : Promise.resolve({}); 65 | this.resourceLoader = Promise.all([resolvePromise, filesAppenderPromise]).then(([resolvedData]) => { 66 | this.resolvedData = resolvedData; 67 | ModuleRegistry.notifyListeners('reactModuleContainer.componentReady', this.manifest.component); 68 | }).catch(err => { 69 | ModuleRegistry.notifyListeners('reactModuleContainer.error', new LazyComponentLoadingError(this.manifest.component, err)); 70 | this.setState({ 71 | error: err, 72 | }); 73 | }); 74 | 75 | // This component instance will be thrown away and a new one created when the promise is resolved. 76 | // Store the promise and reference to the data from this instance 77 | this.setupSuspensePayload(); 78 | } 79 | 80 | componentWillUnmount() { 81 | if (this.manifest.unloadStylesOnDestroy !== false) { 82 | unloadStyles(document, this.manifest.files); 83 | } 84 | ModuleRegistry.notifyListeners('reactModuleContainer.componentWillUnmount', this.manifest.component); 85 | } 86 | 87 | renderComponent() { 88 | this.handleSuspenseRender(); 89 | 90 | // Make sure any context does not propagate to any children (otherwise this can enter an infinite loop since it's working on the same payload instance) 91 | return this.state.component ? : null; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/context.js: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | /** @deprecated `ReactModuleContainerContext` exists as a workaround while transitioning from legacy features, please do not use */ 4 | export const ReactModuleContainerContext = createContext(null); 5 | ReactModuleContainerContext.displayName = 'ReactModuleContainerContext(deprecated)'; 6 | -------------------------------------------------------------------------------- /src/demo/EventListener.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ModuleRegistry from '../module-registry'; 3 | 4 | export class EventsListener extends React.Component { 5 | constructor(props) { 6 | super(props); 7 | this.state = { 8 | gotStartLoadingEvent: false, 9 | gotComponentReady: false, 10 | gotComponentWillUnmount: false 11 | }; 12 | } 13 | 14 | UNSAFE_componentWillMount() { 15 | this.unSubscribeStartLoading = ModuleRegistry.addListener('reactModuleContainer.componentStartLoading', () => { 16 | this.setState({gotStartLoadingEvent: true}); 17 | }); 18 | 19 | this.unSubscribeComponentReady = ModuleRegistry.addListener('reactModuleContainer.componentReady', () => { 20 | this.setState({gotComponentReady: true}); 21 | }); 22 | 23 | this.unSubscribeComponentWillUnmount = ModuleRegistry.addListener('reactModuleContainer.componentWillUnmount', () => { 24 | this.setState({gotComponentWillUnmount: true}); 25 | }); 26 | } 27 | 28 | componentWillUnmount() { 29 | this.unSubscribeStartLoading.remove(); 30 | this.unSubscribeComponentReady.remove(); 31 | this.unSubscribeComponentWillUnmount.remove(); 32 | } 33 | 34 | render() { 35 | return (
36 |
gotStartLoadingEvent: 37 | {this.state.gotStartLoadingEvent ? 'true' : 'false'}
38 |
gotComponentReady: {this.state.gotComponentReady ? 'true' : 'false'} 39 |
40 |
gotComponentWillUnmount: 41 | {this.state.gotComponentWillUnmount ? 'true' : 'false'}
42 |
); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/demo/demo-4.scss: -------------------------------------------------------------------------------- 1 | :global { 2 | .demo-4 { 3 | color: rgba(4, 4, 4, 1); 4 | } 5 | .demo-5 { 6 | display: none; 7 | color: rgba(0, 0, 0, 0); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/demo/demo-5.scss: -------------------------------------------------------------------------------- 1 | :global { 2 | .demo-4 { 3 | color: rgba(0, 0, 0, 0); 4 | display: none; 5 | } 6 | .demo-5 { 7 | color: rgba(5, 5, 5, 1); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/demo/demo-shared.scss: -------------------------------------------------------------------------------- 1 | :global { 2 | .demo-shared { 3 | background-color: rgba(200, 200, 200, 1); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/demo/demo.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import {render} from 'react-dom'; 4 | import {createStore} from 'redux'; 5 | import {Provider, connect} from 'react-redux'; 6 | import ModuleRegistry from '../module-registry'; 7 | import {EventsListener} from './EventListener'; 8 | import {Router, Route, browserHistory, Link, IndexRoute, withRouter} from 'react-router'; 9 | 10 | import {activeLink} from './demo.scss'; 11 | 12 | const store = createStore((state = 'react-input-value', action) => { 13 | return action.type === 'assign' ? action.value : state; 14 | }); 15 | const withStore = connect( 16 | state => ({value: state}), 17 | dispatch => ({assign: value => dispatch({type: 'assign', value})}) 18 | ); 19 | 20 | const topology = { 21 | staticsUrl: 'http://localhost:3200/lazy/', 22 | baseUrl: 'http://localhost:3200/' 23 | }; 24 | const rootElement = document.getElementById('root'); 25 | const MyApp = {MyNgComp: ModuleRegistry.component('MyApp.MyNgComp')}; 26 | const MyApp2 = {MyNgComp: ModuleRegistry.component('MyApp2.MyNgComp')}; 27 | const MyApp3 = {MyReactComp: ModuleRegistry.component('MyApp3.MyReactComp')}; 28 | const MyApp4 = {MyNgComp: ModuleRegistry.component('MyApp4.MyNgComp')}; 29 | const MyApp5 = {MyNgComp: ModuleRegistry.component('MyApp5.MyNgComp')}; 30 | const MyApp5NoUnloadCss = {MyNgComp: ModuleRegistry.component('MyApp5NoUnloadCss.MyNgComp')}; 31 | const MyApp6 = {MyReactCompCrossOrigin: ModuleRegistry.component('MyApp6.MyReactCompCrossOrigin')}; 32 | const MyApp7 = {MyReactComp: ModuleRegistry.component('MyApp7.MyReactComp')}; 33 | const MyApp8 = {MyReactComp: ModuleRegistry.component('MyApp8.MyReactComp')}; 34 | 35 | const SplatLink = withRouter(props => { 36 | const newProps = {to: props.to, className: props.className, style: props.style}; 37 | if (props.location.pathname.indexOf(props.to) === 0) { 38 | newProps.style = {...props.style, ...props.activeStyle}; 39 | newProps.className = `${props.className || ''} ${props.activeClassName || ''}`; 40 | } 41 | return {props.children}; 42 | }); 43 | const Navigation = withStore(props => ( 44 |
45 | 46 | props.assign(e.target.value)}/> 47 |
48 | ng-router-app  49 | ui-router-app  50 | rt-router-app  51 | ng-router-app  52 | ng-router-app4  53 | ng-router-app5  54 | ng-router-app5-no-unload-css  55 | rt-router-app6  56 | rt-router-app7  57 | rt-router-app8  58 |
{props.children}
59 |
60 | )); 61 | Navigation.propTypes = { 62 | children: PropTypes.any 63 | }; 64 | 65 | const Home = () => hello; 66 | 67 | const App = withStore(withRouter(props => )); 68 | const App2 = withStore(withRouter(props => )); 69 | const App3 = withStore(withRouter(props => )); 70 | const App4 = withStore(withRouter(props => )); 71 | const App5 = withStore(withRouter(props => )); 72 | const App5NoUnloadModule = withStore(withRouter(props => )); 73 | const App6 = withStore(withRouter(props => )); 74 | const App7 = withStore(withRouter(props => )); 75 | const App8 = withStore(withRouter(props => )); 76 | 77 | render( 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | , 94 | rootElement 95 | ); 96 | -------------------------------------------------------------------------------- /src/demo/demo.scss: -------------------------------------------------------------------------------- 1 | .active-link { 2 | background-color: yellow; 3 | } 4 | -------------------------------------------------------------------------------- /src/demo/module.js: -------------------------------------------------------------------------------- 1 | /* global React, AngularLazyComponent, ReactLazyComponent, ModuleRegistry */ 2 | import {Link} from 'react-router'; 3 | import PropTypes from 'prop-types'; 4 | 5 | export class MyNgComp extends AngularLazyComponent { 6 | constructor(props) { 7 | super(props, { 8 | files: [`${props.topology.staticsUrl}angular-module.bundle.js`], 9 | module: 'myApp', 10 | component: 'my-comp' 11 | }); 12 | } 13 | } 14 | 15 | export class MyNgComp2 extends AngularLazyComponent { 16 | constructor(props) { 17 | super(props, { 18 | files: [`${props.topology.staticsUrl}angular-module.bundle.js`], 19 | resolve: () => { 20 | const experimentsPromise = Promise.resolve({'specs.fed.ReactModuleContainerWithResolve': true}); 21 | const customDataPromise = Promise.resolve({user: 'xiw@wix.com'}); 22 | return Promise.all([experimentsPromise, customDataPromise]).then(results => { 23 | return { 24 | experiments: results[0], 25 | customData: results[1] 26 | }; 27 | }); 28 | }, 29 | module: 'myApp2', 30 | component: 'my-comp' 31 | }); 32 | } 33 | } 34 | 35 | export class MyReactComp extends ReactLazyComponent { 36 | constructor(props) { 37 | super(props, { 38 | files: [`${props.topology.staticsUrl}react-module.bundle.js`], 39 | resolve: () => { 40 | const experimentsPromise = Promise.resolve({'specs.fed.ReactModuleContainerWithResolve': true}); 41 | const customDataPromise = Promise.resolve({user: 'xiw@wix.com'}); 42 | return Promise.all([experimentsPromise, customDataPromise]).then(results => { 43 | return { 44 | experiments: results[0], 45 | customData: results[1] 46 | }; 47 | }); 48 | }, 49 | component: 'MyApp3.RealReactComp' 50 | }); 51 | } 52 | } 53 | 54 | export class MyReactCompCrossOrigin extends ReactLazyComponent { 55 | constructor(props) { 56 | super(props, { 57 | files: [`${props.topology.staticsUrl}react-module.bundle.js`], 58 | crossorigin: true, 59 | component: 'MyApp6.RealReactCompCrossOrigin' 60 | }); 61 | } 62 | } 63 | 64 | class Hello extends React.Component { 65 | constructor(props) { 66 | super(props); 67 | this.state = {counter: 0}; 68 | } 69 | 70 | handleClick() { 71 | this.setState({counter: this.state.counter + 1}); 72 | } 73 | 74 | render() { 75 | return (
76 |
this.handleClick()}> 77 |
React Counter (click me): {this.state.counter}!!!
78 |
{this.props.value}
79 |
80 |
81 | ng-route-app  82 | ui-route-app  83 |
84 |
); 85 | } 86 | } 87 | Hello.propTypes = { 88 | value: PropTypes.string 89 | }; 90 | 91 | export class MyNgComp4 extends AngularLazyComponent { 92 | constructor(props) { 93 | super(props, { 94 | files: [ 95 | `${props.topology.staticsUrl}angular-module.bundle.js`, 96 | `${props.topology.baseUrl}demo-shared.css`, 97 | `${props.topology.baseUrl}demo-4.css` 98 | ], 99 | module: 'myApp4', 100 | component: 'my-comp' 101 | }); 102 | } 103 | } 104 | 105 | export class MyNgComp5 extends AngularLazyComponent { 106 | constructor(props) { 107 | super(props, { 108 | unloadStylesOnDestroy: true, 109 | files: [ 110 | `${props.topology.staticsUrl}angular-module.bundle.js`, 111 | `${props.topology.baseUrl}demo-shared.css`, 112 | `${props.topology.baseUrl}demo-5.css` 113 | ], 114 | module: 'myApp5', 115 | component: 'my-comp' 116 | }); 117 | } 118 | } 119 | 120 | export class MyNgApp5NoUnloadCss extends MyNgComp5 { 121 | constructor(props) { 122 | super(props); 123 | this.manifest.unloadStylesOnDestroy = false; 124 | } 125 | } 126 | 127 | export class MyReactComp7 extends ReactLazyComponent { 128 | constructor(props) { 129 | super(props, { 130 | files: [ 131 | `${props.topology.staticsUrl}react-module.bundle.js`, 132 | `${props.topology.baseUrl}demo-shared.css`, 133 | `${props.topology.baseUrl}demo-4.css` 134 | ], 135 | component: 'MyApp7.RealReactComp' 136 | }); 137 | } 138 | } 139 | 140 | export class MyReactComp8 extends ReactLazyComponent { 141 | constructor(props) { 142 | super(props, { 143 | files: [ 144 | `${props.topology.staticsUrl}react-module.bundle.js`, 145 | `${props.topology.baseUrl}demo-shared.css`, 146 | `${props.topology.baseUrl}demo-5.css` 147 | ], 148 | component: 'MyApp7.RealReactComp', 149 | unloadStylesOnDestroy: false 150 | }); 151 | } 152 | } 153 | 154 | ModuleRegistry.registerComponent('MyApp.MyNgComp', () => MyNgComp); 155 | ModuleRegistry.registerComponent('MyApp2.MyNgComp', () => MyNgComp2); 156 | ModuleRegistry.registerComponent('MyApp3.MyReactComp', () => MyReactComp); 157 | ModuleRegistry.registerComponent('Hello', () => Hello); 158 | ModuleRegistry.registerComponent('MyApp4.MyNgComp', () => MyNgComp4); 159 | ModuleRegistry.registerComponent('MyApp5.MyNgComp', () => MyNgComp5); 160 | ModuleRegistry.registerComponent('MyApp5NoUnloadCss.MyNgComp', () => MyNgApp5NoUnloadCss); 161 | ModuleRegistry.registerComponent('MyApp6.MyReactCompCrossOrigin', () => MyReactCompCrossOrigin); 162 | ModuleRegistry.registerComponent('MyApp7.MyReactComp', () => MyReactComp7); 163 | ModuleRegistry.registerComponent('MyApp8.MyReactComp', () => MyReactComp8); 164 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export {default as ModuleRegistry} from './module-registry'; 2 | export {default as ReactLazyComponent} from './react-lazy-component'; 3 | export {default as AngularLazyComponent} from './angular-lazy-component'; 4 | export {default as ReactLoadableComponent} from './react-loadable-component'; 5 | export { ReactModuleContainerContext } from './context'; 6 | -------------------------------------------------------------------------------- /src/lazy/angular-module.js: -------------------------------------------------------------------------------- 1 | /* global angular */ 2 | const myApp = angular.module('myApp', ['ngRoute']); 3 | 4 | class MyCompController { 5 | constructor(props) { 6 | this.value = 'angular-input-value'; 7 | this.props = props; 8 | } 9 | } 10 | 11 | myApp.config(($routeProvider, $locationProvider) => { 12 | $locationProvider.html5Mode({enabled: true, requireBase: false}); 13 | $routeProvider 14 | .when('/ng-router-app/a', {template: '
BAZINGA!
'}) 15 | .when('/ng-router-app/b', {template: '
STAGADISH!
'}) 16 | .otherwise('/ng-router-app/a'); 17 | }); 18 | 19 | myApp.component('myComp', { 20 | template: 21 | `
22 |
{{$ctrl.props().value}}
23 | 24 |
25 | a 26 | b 27 | rt-router-app 28 | 29 | 30 |
31 |
`, 32 | controller: MyCompController 33 | }); 34 | 35 | const myApp2 = angular.module('myApp2', ['ui.router']); 36 | 37 | class MyCompController2 { 38 | constructor(props) { 39 | this.value = 'angular-input-value'; 40 | this.props = props; 41 | } 42 | } 43 | 44 | myApp2.config(($stateProvider, $locationProvider, $urlRouterProvider) => { 45 | $locationProvider.html5Mode({enabled: true, requireBase: false}); 46 | $stateProvider.state('a', {url: '/ui-router-app/a', template: 'BAZINGA!'}); 47 | $stateProvider.state('b', {url: '/ui-router-app/b', template: 'STAGADISH!'}); 48 | $urlRouterProvider.otherwise('/ui-router-app/a'); 49 | }); 50 | 51 | myApp2.component('myComp', { 52 | template: 53 | `
54 |
{{$ctrl.props().value}}
55 |
{{$ctrl.props().experiments}}
56 |
{{$ctrl.props().customData}}
57 | 58 |
59 | a 60 | b 61 | rt-router-app 62 |
63 | 64 |
65 |
`, 66 | controller: MyCompController2 67 | }); 68 | 69 | const SHARED_TEMPLATE = ` 70 |
71 |
demo-4
72 |
demo-5
73 |
`; 74 | 75 | angular.module('myApp4', []) 76 | .component('myComp', {template: SHARED_TEMPLATE}); 77 | 78 | angular.module('myApp5', []) 79 | .component('myComp', {template: SHARED_TEMPLATE}); 80 | -------------------------------------------------------------------------------- /src/lazy/react-module.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import {Link} from 'react-router'; 4 | import ModuleRegistry from '../module-registry'; 5 | 6 | const RealReactComp = props => ( 7 |
8 |
{props.value}
9 |
{JSON.stringify(props.experiments)}
10 |
{JSON.stringify(props.customData)}
11 |
12 | ng-route-app  13 | ui-route-app  14 |
15 |
16 | ); 17 | RealReactComp.propTypes = { 18 | value: PropTypes.any, 19 | experiments: PropTypes.any, 20 | customData: PropTypes.any 21 | }; 22 | ModuleRegistry.registerComponent('MyApp3.RealReactComp', () => RealReactComp); 23 | 24 | const RealReactCompCrossOrigin = props => ( 25 |
26 |
{props.value}
27 |
28 | ); 29 | RealReactCompCrossOrigin.propTypes = { 30 | value: PropTypes.any 31 | }; 32 | ModuleRegistry.registerComponent('MyApp6.RealReactCompCrossOrigin', () => RealReactCompCrossOrigin); 33 | 34 | const DemoReactComp = () => ( 35 |
36 |
demo-4
37 |
demo-5
38 |
39 | ); 40 | ModuleRegistry.registerComponent('MyApp7.RealReactComp', () => DemoReactComp); 41 | -------------------------------------------------------------------------------- /src/module-registry.js: -------------------------------------------------------------------------------- 1 | import set from 'lodash/set'; 2 | import unset from 'lodash/unset'; 3 | import forEach from 'lodash/forEach'; 4 | import uniqueId from 'lodash/uniqueId'; 5 | import { 6 | ListenerCallbackError, UnregisteredComponentUsedError, 7 | UnregisteredMethodInvokedError 8 | } from './ReactModuleContainerErrors'; 9 | 10 | class ModuleRegistry { 11 | constructor() { 12 | this.registeredComponents = {}; 13 | this.registeredMethods = {}; 14 | this.eventListeners = {}; 15 | this.modules = {}; 16 | } 17 | 18 | cleanAll() { 19 | this.registeredComponents = {}; 20 | this.registeredMethods = {}; 21 | this.eventListeners = {}; 22 | this.modules = {}; 23 | } 24 | 25 | registerModule(globalID, ModuleFactory, args = []) { 26 | if (this.modules[globalID]) { 27 | throw new Error(`A module with id "${globalID}" is already registered`); 28 | } 29 | 30 | this.modules[globalID] = new ModuleFactory(...args); 31 | } 32 | 33 | getModule(globalID) { 34 | return this.modules[globalID]; 35 | } 36 | 37 | getAllModules() { 38 | return Object.keys(this.modules).map(moduleId => this.modules[moduleId]); 39 | } 40 | 41 | registerComponent(globalID, generator) { 42 | this.registeredComponents[globalID] = generator; 43 | } 44 | 45 | component(globalID) { 46 | const generator = this.registeredComponents[globalID]; 47 | if (!generator) { 48 | this.notifyListeners('reactModuleContainer.error', new UnregisteredComponentUsedError(globalID)); 49 | return undefined; 50 | } 51 | return generator(); 52 | } 53 | 54 | addListener(globalID, callback) { 55 | const callbackKey = uniqueId('eventListener'); 56 | set(this.eventListeners, [globalID, callbackKey], callback); 57 | return { 58 | remove: () => unset(this.eventListeners[globalID], callbackKey) 59 | }; 60 | } 61 | 62 | notifyListeners(globalID, ...args) { 63 | const listenerCallbacks = this.eventListeners[globalID]; 64 | if (!listenerCallbacks) { 65 | return; 66 | } 67 | forEach(listenerCallbacks, callback => invokeSafely(globalID, callback, args)); 68 | } 69 | 70 | registerMethod(globalID, generator) { 71 | this.registeredMethods[globalID] = generator; 72 | } 73 | 74 | invoke(globalID, ...args) { 75 | const generator = this.registeredMethods[globalID]; 76 | if (!generator) { 77 | this.notifyListeners('reactModuleContainer.error', new UnregisteredMethodInvokedError(globalID)); 78 | return undefined; 79 | } 80 | const method = generator(); 81 | return method(...args); 82 | } 83 | } 84 | 85 | let singleton; 86 | if (typeof window !== 'undefined') { 87 | singleton = window.ModuleRegistry || new ModuleRegistry(); 88 | window.ModuleRegistry = singleton; 89 | } else { 90 | singleton = new ModuleRegistry(); 91 | } 92 | export default singleton; 93 | 94 | function invokeSafely(globalID, callback, args) { 95 | try { 96 | callback(...args); 97 | } catch (err) { 98 | singleton.notifyListeners('reactModuleContainer.error', new ListenerCallbackError(globalID, err)); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/react-lazy-component.js: -------------------------------------------------------------------------------- 1 | import ModuleRegistry from './module-registry'; 2 | import BaseLazyComponent from './base-lazy-component'; 3 | 4 | class ReactLazyComponent extends BaseLazyComponent { 5 | constructor(props, manifest) { 6 | super(props, manifest); 7 | this.state = {component: null}; 8 | } 9 | 10 | componentDidMount() { 11 | this.resourceLoader.then(() => { 12 | const component = ModuleRegistry.component(this.manifest.component); 13 | this.setState({component}); 14 | }); 15 | } 16 | 17 | render() { 18 | return this.renderComponent(); 19 | } 20 | } 21 | 22 | export default ReactLazyComponent; 23 | -------------------------------------------------------------------------------- /src/react-loadable-component.js: -------------------------------------------------------------------------------- 1 | import BaseLazyComponent from './base-lazy-component'; 2 | 3 | export default function ReactLoadableComponent(name, resolve, files = []) { 4 | return class LoadableComponent extends BaseLazyComponent { 5 | constructor(props) { 6 | super(props, {component: name, files, resolve}); 7 | this.state = {component: null}; 8 | } 9 | 10 | componentDidMount() { 11 | this.resourceLoader.then(() => { 12 | if (this.resolvedData) { 13 | const component = this.resolvedData.default || this.resolvedData; 14 | if (component) { 15 | this.setState({ component }); 16 | } 17 | } 18 | }); 19 | } 20 | 21 | render() { 22 | if (this.state.error) { 23 | throw this.state.error; 24 | } 25 | 26 | return this.renderComponent(); 27 | } 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /src/react-module-container.js: -------------------------------------------------------------------------------- 1 | import ModuleRegistry from './module-registry'; 2 | import ReactLazyComponent from './react-lazy-component'; 3 | import AngularLazyComponent from './angular-lazy-component'; 4 | import ReactLoadableComponent from './react-loadable-component'; 5 | 6 | window.ModuleRegistry = ModuleRegistry; 7 | window.ReactLazyComponent = ReactLazyComponent; 8 | window.AngularLazyComponent = AngularLazyComponent; 9 | window.ReactLoadableComponent = ReactLoadableComponent; 10 | -------------------------------------------------------------------------------- /src/tag-appender.js: -------------------------------------------------------------------------------- 1 | import ModuleRegistry from './module-registry'; 2 | import {FileAppenderLoadError} from './ReactModuleContainerErrors'; 3 | 4 | const requireCache = {}; 5 | 6 | function noprotocol(url) { 7 | return url.replace(/^.*:\/\//, '//'); 8 | } 9 | 10 | export function createLinkElement(url) { 11 | const fileref = document.createElement('LINK'); 12 | fileref.setAttribute('rel', 'stylesheet'); 13 | fileref.setAttribute('type', 'text/css'); 14 | fileref.setAttribute('href', url); 15 | return fileref; 16 | } 17 | 18 | export function createScriptElement(url, crossorigin) { 19 | const fileref = document.createElement('SCRIPT'); 20 | fileref.setAttribute('type', 'text/javascript'); 21 | fileref.setAttribute('src', url); 22 | if (crossorigin) { 23 | fileref.setAttribute('crossorigin', 'anonymous'); 24 | } 25 | return fileref; 26 | } 27 | 28 | export function tagAppender(url, filetype, crossorigin) { 29 | const styleSheets = document.styleSheets; 30 | return requireCache[url] = new Promise((resolve, reject) => { 31 | if (window.requirejs && filetype === 'js') { 32 | window.requirejs([url], resolve, reject); 33 | return; 34 | } else if (url in requireCache) { 35 | // requireCache[url].then(resolve, reject); 36 | // return; 37 | } 38 | 39 | const fileref = (filetype === 'css') ? 40 | createLinkElement(url) : 41 | createScriptElement(url, crossorigin); 42 | 43 | let done = false; 44 | document.getElementsByTagName('head')[0].appendChild(fileref); 45 | fileref.onerror = function () { 46 | fileref.onerror = fileref.onload = fileref.onreadystatechange = null; 47 | delete requireCache[url]; 48 | ModuleRegistry.notifyListeners('reactModuleContainer.error', new FileAppenderLoadError(url)); 49 | reject(new Error(`Could not load URL ${url}`)); 50 | }; 51 | fileref.onload = fileref.onreadystatechange = function () { 52 | if (!done && (!this.readyState || this.readyState === 'loaded' || this.readyState === 'complete')) { 53 | done = true; 54 | fileref.onerror = fileref.onload = fileref.onreadystatechange = null; 55 | resolve(); 56 | } 57 | }; 58 | if (filetype === 'css' && navigator.userAgent.match(' Safari/') && !navigator.userAgent.match(' Chrom') && navigator.userAgent.match(' Version/5.')) { 59 | let attempts = 20; 60 | const interval = setInterval(() => { 61 | for (let i = 0; i < styleSheets.length; i++) { 62 | if (noprotocol(`${styleSheets[i].href}`) === noprotocol(url)) { 63 | clearInterval(interval); 64 | fileref.onload(); 65 | return; 66 | } 67 | } 68 | if (--attempts === 0) { 69 | clearInterval(interval); 70 | fileref.onerror(); 71 | } 72 | }, 50); 73 | } 74 | }); 75 | } 76 | 77 | function append(file, crossorigin) { 78 | return tagAppender(file, file.split('.').pop(), crossorigin); 79 | } 80 | 81 | function onCatch(error, optional = false) { 82 | return optional ? Promise.resolve() : Promise.reject(error); 83 | } 84 | 85 | function appendEntry(entry, crossorigin) { 86 | if (typeof entry === 'object') { 87 | const {optional, url} = entry; 88 | return append(url, crossorigin).catch(err => onCatch(err, optional)); 89 | } else { 90 | return append(entry, crossorigin).catch(err => onCatch(err)); 91 | } 92 | } 93 | 94 | export function filesAppender(entries, crossorigin) { 95 | return Promise.all(entries.map(entry => { 96 | if (Array.isArray(entry)) { 97 | return entry.reduce( 98 | (promise, entryItem) => promise.then(() => appendEntry(entryItem, crossorigin)), 99 | Promise.resolve()); 100 | } else { 101 | return appendEntry(entry, crossorigin); 102 | } 103 | })); 104 | } 105 | 106 | const getStyleSheetLinks = document => 107 | Array.from(document.querySelectorAll('link')) 108 | .filter(link => link.rel === 'stylesheet' && link.href) 109 | .reduceRight((acc, curr) => ({...acc, [noprotocol(curr.href)]: curr}), {}); 110 | 111 | const toUrlString = file => typeof file === 'object' ? file.url : file; 112 | 113 | const getStyleSheetUrls = files => 114 | [].concat(...files) 115 | .map(toUrlString) 116 | .filter(url => url.endsWith('.css')) 117 | .map(noprotocol); 118 | 119 | export function unloadStyles(document, files) { 120 | const links = getStyleSheetLinks(document); 121 | getStyleSheetUrls(files).forEach(file => { 122 | const link = links[file]; 123 | if (link) { 124 | link.parentNode.removeChild(link); 125 | } 126 | }); 127 | } 128 | -------------------------------------------------------------------------------- /test/e2e/app.e2e.js: -------------------------------------------------------------------------------- 1 | describe('React application', () => { 2 | describe('life cycle events', () => { 3 | it('should not have navigation events', () => { 4 | browser.get('/'); 5 | expect($('#got-start-loading').getText()).toBe('false'); 6 | expect($('#got-component-ready').getText()).toBe('false'); 7 | expect($('#got-component-will-unmount').getText()).toBe('false'); 8 | }); 9 | 10 | it('should have navigation events', () => { 11 | browser.get('/ng-router-app4'); 12 | expect($('#got-start-loading').getText()).toBe('true'); 13 | expect($('#got-component-ready').getText()).toBe('true'); 14 | expect($('#got-component-will-unmount').getText()).toBe('false'); 15 | $$('.nav').get(5).click(); 16 | expect($('#got-component-will-unmount').getText()).toBe('true'); 17 | }); 18 | }); 19 | 20 | describe('open page', () => { 21 | it('should display hello', () => { 22 | browser.get('/'); 23 | expect($('#hello').getText()).toBe('hello'); 24 | }); 25 | 26 | ['ng', 'ui'].forEach((router, index) => describe(`/${router}-router-app/`, () => { 27 | it(`should display ${router} router app`, () => { 28 | browser.get(`/${router}-router-app/`); 29 | expect($('#value-in-angular').getText()).toBe('react-input-value'); 30 | expect($(`${router}-view`).getText()).toBe('BAZINGA!'); 31 | expect($('#value-in-react').getText()).toBe('angular-input-value'); 32 | expect($('#counter').getText()).toBe('0'); 33 | }); 34 | 35 | it('should update inputs in nested apps', () => { 36 | browser.get(`/${router}-router-app/`); 37 | $('#counter').click(); 38 | $('#angular-input').sendKeys('123'); 39 | $('#react-input').sendKeys('123'); 40 | expect($('#value-in-angular').getText()).toBe('react-input-value123'); 41 | expect($('#value-in-react').getText()).toBe('angular-input-value123'); 42 | expect($('#counter').getText()).toBe('1'); 43 | }); 44 | 45 | it('should support internal navigations', () => { 46 | browser.get(`/${router}-router-app/`); 47 | $('#counter').click(); 48 | $('#angular-input').sendKeys('123'); 49 | $('#react-input').sendKeys('123'); 50 | $('#stagadish').click(); 51 | expect($$('.nav').get(router === 'ng' ? 3 : 1).getCssValue('background-color')).toBe('rgba(255, 255, 0, 1)'); 52 | expect($('#value-in-angular').getText()).toBe('react-input-value123'); 53 | expect($('#value-in-react').getText()).toBe('angular-input-value123'); 54 | expect($('#counter').getText()).toBe('1'); 55 | expect($(`${router}-view`).getText()).toBe('STAGADISH!'); 56 | }); 57 | 58 | it('should be able to navigate from within nested app', () => { 59 | browser.get(`/${router}-router-app/a`); 60 | expect($$('.nav').get(index).getCssValue('background-color')).toBe('rgba(255, 255, 0, 1)'); 61 | $('#react-app-link').click(); 62 | expect($$('.nav').get(2).getCssValue('background-color')).toBe('rgba(255, 255, 0, 1)'); 63 | $$('.react-link').get(index).click(); 64 | expect($$('.nav').get(index).getCssValue('background-color')).toBe('rgba(255, 255, 0, 1)'); 65 | }); 66 | 67 | it('should be able to navigate from react embedded in angular', () => { 68 | browser.get(`/${router}-router-app/`); 69 | $('#react-input').sendKeys('123'); 70 | $$('.react-link').get(index ? 0 : 1).click(); 71 | expect($$('.nav').get(index ? 0 : 1).getCssValue('background-color')).toBe('rgba(255, 255, 0, 1)'); 72 | expect($('#value-in-angular').getText()).toBe('react-input-value123'); 73 | }); 74 | })); 75 | }); 76 | 77 | describe('manifest with resolve', () => { 78 | ['ui', 'rt'].forEach(router => describe(`/${router}-router-app/`, () => { 79 | it(`should display the resolved data`, () => { 80 | browser.get(`/${router}-router-app/`); 81 | expect($('#value-of-resolved-experiments').getText()).toBe(JSON.stringify({'specs.fed.ReactModuleContainerWithResolve': true})); 82 | expect($('#value-of-resolved-custom-data').getText()).toBe(JSON.stringify({user: 'xiw@wix.com'})); 83 | }); 84 | })); 85 | }); 86 | 87 | describe('manifest with crossorigin', () => { 88 | it(`should load the component with the crossorigin attribute`, () => { 89 | browser.get(`/rt-router-app6/`); 90 | const lazyComponentScriptsWithCrossOrigin = $$('script') 91 | .filter(element => element.getAttribute('src').then(value => value.endsWith('react-module.bundle.js'))) 92 | .filter(element => element.getAttribute('crossorigin').then(value => value !== null)); 93 | 94 | expect(lazyComponentScriptsWithCrossOrigin.count()).toBe(1); 95 | }); 96 | }); 97 | 98 | describe('unload styles on destroy', () => { 99 | 100 | const linksToModuleWithUnloadCss = { 101 | notDefined: 4, 102 | true: 5, 103 | false: 6 104 | }; 105 | 106 | const linksToReactModuleWithUnloadCss = { 107 | notDefined: 8, 108 | false: 9 109 | }; 110 | 111 | beforeEach(() => { 112 | browser.get('/'); 113 | }); 114 | 115 | it('should by default unload css files specified inside angular manifest', () => { 116 | 117 | $$('.nav').get(linksToModuleWithUnloadCss.notDefined).click(); 118 | expect(getStyleSheetHrefs()).toEqual([ 119 | 'http://localhost:3200/demo.css', 120 | 'http://localhost:3200/demo-shared.css', 121 | 'http://localhost:3200/demo-4.css' 122 | ]); 123 | 124 | expectIsHidden('.demo-5'); 125 | 126 | $$('.nav').get(linksToModuleWithUnloadCss.false).click(); 127 | expect(getStyleSheetHrefs()).toEqual([ 128 | 'http://localhost:3200/demo.css', 129 | 'http://localhost:3200/demo-shared.css', 130 | 'http://localhost:3200/demo-5.css' 131 | ]); 132 | }); 133 | 134 | it('should not unload css files specified inside angular manifest', () => { 135 | 136 | $$('.nav').get(linksToModuleWithUnloadCss.false).click(); 137 | expect(getStyleSheetHrefs()).toEqual([ 138 | 'http://localhost:3200/demo.css', 139 | 'http://localhost:3200/demo-shared.css', 140 | 'http://localhost:3200/demo-5.css' 141 | ]); 142 | 143 | expectIsHidden('.demo-4'); 144 | 145 | $$('.nav').get(linksToModuleWithUnloadCss.notDefined).click(); 146 | expect(getStyleSheetHrefs()).toEqual([ 147 | 'http://localhost:3200/demo.css', 148 | 'http://localhost:3200/demo-shared.css', 149 | 'http://localhost:3200/demo-5.css', 150 | 'http://localhost:3200/demo-shared.css', 151 | 'http://localhost:3200/demo-4.css' 152 | ]); 153 | }); 154 | 155 | it('should unload css files specified inside angular manifest', () => { 156 | 157 | $$('.nav').get(linksToModuleWithUnloadCss.true).click(); 158 | expect(getStyleSheetHrefs()).toEqual([ 159 | 'http://localhost:3200/demo.css', 160 | 'http://localhost:3200/demo-shared.css', 161 | 'http://localhost:3200/demo-5.css' 162 | ]); 163 | expect($('.demo-shared').getCssValue('background-color')).toBe('rgba(200, 200, 200, 1)'); 164 | expect($('.demo-5').getCssValue('color')).toBe('rgba(5, 5, 5, 1)'); 165 | expectIsHidden('.demo-4'); 166 | 167 | $$('.nav').get(linksToModuleWithUnloadCss.notDefined).click(); 168 | expect(getStyleSheetHrefs()).toEqual([ 169 | 'http://localhost:3200/demo.css', 170 | 'http://localhost:3200/demo-shared.css', 171 | 'http://localhost:3200/demo-4.css' 172 | ]); 173 | expect($('.demo-shared').getCssValue('background-color')).toBe('rgba(200, 200, 200, 1)'); 174 | expect($('.demo-4').getCssValue('color')).toBe('rgba(4, 4, 4, 1)'); 175 | }); 176 | 177 | it('should unload css files specified inside react manifest', () => { 178 | $$('.nav').get(linksToReactModuleWithUnloadCss.notDefined).click(); 179 | $$('.nav').get(linksToModuleWithUnloadCss.false).click(); 180 | expect(getStyleSheetHrefs()).toEqual([ 181 | 'http://localhost:3200/demo.css', 182 | 'http://localhost:3200/demo-shared.css', 183 | 'http://localhost:3200/demo-5.css' 184 | ]); 185 | }); 186 | 187 | it('should not unload css files specified inside react manifest', () => { 188 | $$('.nav').get(linksToReactModuleWithUnloadCss.false).click(); 189 | $$('.nav').get(linksToReactModuleWithUnloadCss.notDefined).click(); 190 | expect(getStyleSheetHrefs()).toEqual([ 191 | 'http://localhost:3200/demo.css', 192 | 'http://localhost:3200/demo-shared.css', 193 | 'http://localhost:3200/demo-5.css', 194 | 'http://localhost:3200/demo-shared.css', 195 | 'http://localhost:3200/demo-4.css' 196 | ]); 197 | }); 198 | 199 | function getStyleSheetHrefs() { 200 | return $$('link').map(elem => elem.getAttribute('href')); 201 | } 202 | 203 | function expectIsHidden(selector) { 204 | expect($(selector).getCssValue('color')).toBe('rgba(0, 0, 0, 0)'); 205 | expect($(selector).getCssValue('display')).toBe('none'); 206 | } 207 | }); 208 | 209 | }); 210 | -------------------------------------------------------------------------------- /test/mocha-setup.js: -------------------------------------------------------------------------------- 1 | import 'global-jsdom/register'; // eslint-disable-line import/no-unresolved 2 | import { configure } from '@testing-library/react'; 3 | import { use } from 'chai'; 4 | import sinonChai from 'sinon-chai'; 5 | 6 | configure({ testIdAttribute: 'data-hook' }); 7 | use(sinonChai); -------------------------------------------------------------------------------- /test/mock/SomeComponent.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ReactLoadableComponent } from '../../src'; 3 | 4 | const SubComponent = ReactLoadableComponent('SomeSubComponentName', () => import('./SomeSubComponent')); 5 | 6 | export default () => ( 7 |
8 |
Hello World!
9 | 10 |
11 | ); -------------------------------------------------------------------------------- /test/mock/SomeSubComponent.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default () =>
; -------------------------------------------------------------------------------- /test/mock/fake-server.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import express from 'express'; 3 | import bodyParser from 'body-parser'; 4 | 5 | const app = express(); 6 | app.use(bodyParser.json()); 7 | 8 | app.use((req, res, next) => { 9 | res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); 10 | res.setHeader('Pragma', 'no-cache'); 11 | res.setHeader('Expires', 0); 12 | return next(); 13 | }); 14 | 15 | app.use('*', (req, res) => { 16 | res.sendFile(path.join(process.cwd(), './src/index.html')); 17 | }); 18 | 19 | const port = process.env.FAKE_SERVER_PORT || 3000; 20 | app.listen(port, () => { 21 | console.log(`Fake server is running on port ${port}`); 22 | }); 23 | 24 | module.exports = app; 25 | -------------------------------------------------------------------------------- /test/module-registry.spec.js: -------------------------------------------------------------------------------- 1 | import 'mocha'; 2 | import chai, {expect} from 'chai'; 3 | import sinon from 'sinon'; 4 | import sinonChai from 'sinon-chai'; 5 | 6 | chai.use(sinonChai); 7 | 8 | import ModuleRegistry from '../src/module-registry'; 9 | import { 10 | ListenerCallbackError, UnregisteredComponentUsedError, 11 | UnregisteredMethodInvokedError 12 | } from '../src/ReactModuleContainerErrors'; 13 | 14 | describe('Module Registry', () => { 15 | beforeEach(() => { 16 | ModuleRegistry.cleanAll(); 17 | }); 18 | 19 | it('should be able to register a module', () => { 20 | class MyModule {} 21 | ModuleRegistry.registerModule('GLOBAL_ID', MyModule); 22 | const result = ModuleRegistry.getModule('GLOBAL_ID'); 23 | expect(result).to.be.an.instanceOf(MyModule); 24 | }); 25 | 26 | it('should be able to pass parameters to the register a module', () => { 27 | class MyModule { 28 | constructor(name) { 29 | this.name = name; 30 | } 31 | } 32 | ModuleRegistry.registerModule('GLOBAL_ID', MyModule, ['DUMMY_NAME']); 33 | const result = ModuleRegistry.getModule('GLOBAL_ID'); 34 | expect(result.name).to.eq('DUMMY_NAME'); 35 | }); 36 | 37 | it('should throw an error if the given module was already registered', () => { 38 | class MyModule {} 39 | expect(() => ModuleRegistry.registerModule('GLOBAL_ID', MyModule)).to.not.throw(); 40 | expect(() => ModuleRegistry.registerModule('GLOBAL_ID', MyModule)).to.throw(); 41 | }); 42 | 43 | it('should be able to get all modules', () => { 44 | class MyModule1 {} 45 | class MyModule2 {} 46 | class MyModule3 {} 47 | ModuleRegistry.registerModule('GLOBAL_ID1', MyModule1); 48 | ModuleRegistry.registerModule('GLOBAL_ID2', MyModule2); 49 | ModuleRegistry.registerModule('GLOBAL_ID3', MyModule3); 50 | 51 | const allModules = ModuleRegistry.getAllModules(); 52 | expect(allModules.length).to.eq(3); 53 | expect(allModules.find(m => m instanceof MyModule1)).to.be.an.instanceOf(MyModule1); 54 | expect(allModules.find(m => m instanceof MyModule2)).to.be.an.instanceOf(MyModule2); 55 | expect(allModules.find(m => m instanceof MyModule3)).to.be.an.instanceOf(MyModule3); 56 | }); 57 | 58 | it('should be able to register a method and call it', () => { 59 | const method = sinon.spy(); 60 | ModuleRegistry.registerMethod('GLOBAL_ID', () => method); 61 | ModuleRegistry.invoke('GLOBAL_ID', 1, 2, 3); 62 | expect(method).calledWith(1, 2, 3); 63 | }); 64 | 65 | it('should be able to register a component', () => { 66 | const component = () => '
FAKE_COMPONENT
'; 67 | ModuleRegistry.registerComponent('GLOBAL_ID', component); 68 | const resultComponent = ModuleRegistry.component('GLOBAL_ID'); 69 | expect(resultComponent).to.eq('
FAKE_COMPONENT
'); 70 | }); 71 | 72 | it('should notify all event listeners', () => { 73 | const listener1 = sinon.spy(); 74 | const listener2 = sinon.spy(); 75 | ModuleRegistry.addListener('GLOBAL_ID', listener1); 76 | ModuleRegistry.addListener('GLOBAL_ID', listener2); 77 | ModuleRegistry.notifyListeners('GLOBAL_ID', 1, 2, 3); 78 | expect(listener1).calledWith(1, 2, 3); 79 | expect(listener2).calledWith(1, 2, 3); 80 | }); 81 | 82 | it('should clean all the methods, components, events, and modules when calling cleanAll', () => { 83 | ModuleRegistry.registerModule('GLOBAL_ID', class MyModule {}); 84 | ModuleRegistry.registerMethod('GLOBAL_ID', () => () => {}); 85 | ModuleRegistry.registerComponent('GLOBAL_ID', () => {}); 86 | ModuleRegistry.addListener('GLOBAL_ID', () => {}); 87 | 88 | ModuleRegistry.cleanAll(); 89 | 90 | expect(ModuleRegistry.getModule('GLOBAL_ID')).to.be.undefined; 91 | expect(ModuleRegistry.notifyListeners('GLOBAL_ID')).to.be.undefined; 92 | expect(ModuleRegistry.component('GLOBAL_ID')).to.be.undefined; 93 | expect(ModuleRegistry.invoke('GLOBAL_ID')).to.be.undefined; 94 | }); 95 | 96 | describe('ReactModuleContainerError', () => { 97 | let reactModuleContainerErrorCallback; 98 | 99 | beforeEach(() => { 100 | reactModuleContainerErrorCallback = sinon.stub(); 101 | ModuleRegistry.addListener('reactModuleContainer.error', reactModuleContainerErrorCallback); 102 | }); 103 | 104 | it('should be fired when trying to invoke an unregistered method', () => { 105 | const unregisteredMethodName = 'unregistered-method'; 106 | const result = ModuleRegistry.invoke(unregisteredMethodName); 107 | expect(reactModuleContainerErrorCallback).calledOnce; 108 | 109 | const errorCallbackArg = reactModuleContainerErrorCallback.getCall(0).args[0]; 110 | 111 | expect(errorCallbackArg).to.be.an.instanceof(UnregisteredMethodInvokedError); 112 | expect(errorCallbackArg.message).to.eq(`ModuleRegistry.invoke ${unregisteredMethodName} used but not yet registered`); 113 | 114 | expect(result).to.eq(undefined); 115 | }); 116 | 117 | it('should be fired when trying to use an unregistered component', () => { 118 | const componentId = 'component-id'; 119 | const resultComponent = ModuleRegistry.component(componentId); 120 | expect(reactModuleContainerErrorCallback).calledOnce; 121 | 122 | const errorCallbackArg = reactModuleContainerErrorCallback.getCall(0).args[0]; 123 | 124 | expect(errorCallbackArg).to.be.an.instanceof(UnregisteredComponentUsedError); 125 | expect(errorCallbackArg.message).to.eq(`ModuleRegistry.component ${componentId} used but not yet registered`); 126 | 127 | expect(resultComponent).to.eq(undefined); 128 | }); 129 | 130 | it('should be fired when a listener callback throws an error', () => { 131 | const someRegisteredMethod = 'someRegisteredMethod'; 132 | const error = new Error(); 133 | ModuleRegistry.addListener(someRegisteredMethod, () => { 134 | throw error; 135 | }); 136 | ModuleRegistry.notifyListeners(someRegisteredMethod); 137 | 138 | const errorCallbackArg = reactModuleContainerErrorCallback.getCall(0).args[0]; 139 | expect(errorCallbackArg).to.be.an.instanceof(ListenerCallbackError); 140 | expect(errorCallbackArg.message).to.eq(`Error in listener callback of module registry method: ${someRegisteredMethod}`); 141 | }); 142 | }); 143 | }); 144 | -------------------------------------------------------------------------------- /test/react-loadable-component.spec.js: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from 'react'; 2 | import { expect } from 'chai'; 3 | import { render, cleanup } from '@testing-library/react'; 4 | import { ReactLoadableComponent, ReactModuleContainerContext } from '../src'; 5 | import sinon from 'sinon'; 6 | 7 | const hooks = { 8 | someComponent: 'some-component-root', 9 | loader: 'loader', 10 | subComponent: 'sub-component-root', 11 | }; 12 | 13 | describe('ReactLoadableComponent', () => { 14 | afterEach(cleanup); 15 | 16 | afterEach(() => sinon.reset()); 17 | 18 | it('render the component', async () => { 19 | const SomeComponent = ReactLoadableComponent('SomeComponent', () => import('./mock/SomeComponent')); 20 | const { findByTestId } = render(); 21 | 22 | const element = await findByTestId(hooks.someComponent); 23 | 24 | expect(element.textContent).to.equal('Hello World!'); 25 | }); 26 | 27 | describe('rendering with suspense support', () => { 28 | const resolver = sinon.fake(async () => { await new Promise(resolve => setTimeout(resolve, 100)); return import('./mock/SomeComponent') }); 29 | 30 | let renderResult, SomeComponent; 31 | 32 | beforeEach(() => { 33 | SomeComponent = ReactLoadableComponent('SomeComponent', resolver); 34 | const wrapper = ({children}) => ( 35 | 36 | Loading...
}>{children} 37 | 38 | ); 39 | 40 | renderResult = render(, { wrapper }); 41 | }); 42 | 43 | it('should show a loader', async () => { 44 | const loader = await renderResult.findByTestId(hooks.loader); 45 | 46 | expect(loader).to.exist; 47 | }); 48 | 49 | it('should handle rerenders', async () => { 50 | await renderResult.findByTestId(hooks.loader); 51 | await renderResult.rerender(); 52 | const loader = await renderResult.findByTestId(hooks.loader); 53 | 54 | await new Promise(resolve => setTimeout(resolve, 1000)); 55 | 56 | expect(loader).to.exist; 57 | }); 58 | 59 | describe('when loader is rendered', () => { 60 | beforeEach(() => renderResult.findByTestId(hooks.loader)); 61 | 62 | it('should render the component after the loader is shown', async () => { 63 | const element = await renderResult.findByTestId(hooks.someComponent); 64 | 65 | expect(element).to.exist; 66 | }); 67 | 68 | describe('when component is rendered', () => { 69 | beforeEach(() => renderResult.findByTestId(hooks.someComponent)); 70 | 71 | it('should call the resolver only once', async () => { 72 | expect(resolver).to.be.calledOnce; 73 | }); 74 | 75 | it('should render the sub component without entering an infinite loop', async () => { 76 | const element = await renderResult.findByTestId(hooks.subComponent); 77 | 78 | expect(element).to.exist; 79 | }); 80 | }); 81 | }); 82 | }); 83 | }); -------------------------------------------------------------------------------- /test/tag-appender.spec.js: -------------------------------------------------------------------------------- 1 | import 'mocha'; 2 | import {expect} from 'chai'; 3 | import {unloadStyles, createLinkElement, filesAppender} from '../src/tag-appender'; 4 | 5 | describe('tag appender', () => { 6 | 7 | beforeEach(function () { 8 | this.jsdom = require('jsdom-global')(); 9 | }); 10 | 11 | afterEach(function () { 12 | this.jsdom(); 13 | }); 14 | 15 | it('should unload css in the right order', () => { 16 | const cssUrl = 'http://example.com/test.css'; 17 | const css1 = createLinkElement(cssUrl); 18 | const css2 = createLinkElement(cssUrl); 19 | 20 | const headElement = document.getElementsByTagName('head')[0]; 21 | headElement.appendChild(css1); 22 | headElement.appendChild(css2); 23 | 24 | unloadStyles(document, [cssUrl]); 25 | 26 | expect(document.getElementsByTagName('link').length).to.equal(1); 27 | expect(document.getElementsByTagName('link')[0]).to.equal(css2); 28 | }); 29 | 30 | it('should unload css for files with optional flag', () => { 31 | const cssUrl = 'http://example.com/test.css'; 32 | const jsUrl = 'http://example.com/test.js'; 33 | 34 | const headElement = document.getElementsByTagName('head')[0]; 35 | headElement.appendChild(createLinkElement(cssUrl)); 36 | headElement.appendChild(createLinkElement(jsUrl)); 37 | 38 | const link1 = {url: cssUrl, optional: true}; 39 | const link2 = {url: jsUrl, optional: true}; 40 | 41 | unloadStyles(document, [link1, link2]); 42 | 43 | expect(document.getElementsByTagName('link').length).to.equal(1); 44 | }); 45 | 46 | it('filesAppender should set optional flag to false by default', done => { 47 | const link1 = {url: 'http://123.js/'}; 48 | const link2 = {url: 'http://456.js/'}; 49 | 50 | filesAppender([link1, link2]).catch(() => done()); 51 | setTimeout(() => { 52 | document.getElementsByTagName('script')[0].onerror(); 53 | }, 100); 54 | }).timeout(1000); 55 | 56 | it('filesAppender should support optional flag', done => { 57 | const link1 = {url: 'http://123.js/', optional: true}; 58 | const link2 = {url: 'http://456.js/', optional: true}; 59 | 60 | filesAppender([link1, link2]).then(() => done()); 61 | 62 | setTimeout(() => { 63 | document.getElementsByTagName('script')[0].onerror(); 64 | document.getElementsByTagName('script')[1].onerror(); 65 | }, 100); 66 | }).timeout(1000); 67 | 68 | }); 69 | -------------------------------------------------------------------------------- /wallaby.js: -------------------------------------------------------------------------------- 1 | module.exports = require('@wix/yoshi/config/wallaby-mocha'); 2 | --------------------------------------------------------------------------------