├── .gitignore ├── .npmignore ├── .prettierrc ├── .storybook ├── addons.js ├── config.js ├── msg-logger │ ├── constants.ts │ ├── index.ts │ └── register.tsx └── webpack.config.js ├── LICENSE ├── README.md ├── package.json ├── src ├── components │ ├── RemoteRenderProvider.tsx │ ├── Renderer.tsx │ └── withRemoteRender.tsx ├── index.ts ├── model │ ├── default-service.ts │ ├── dummy-service.ts │ └── dummy-transport.ts ├── types │ ├── base.ts │ ├── messages.ts │ ├── service.ts │ └── transport.ts └── utils.ts ├── stories ├── defaultServiceStory.tsx ├── dummyServiceStory.tsx ├── index.ts └── utils.tsx ├── test ├── components │ ├── RemoteRenderProvider.test.tsx │ ├── Renderer.test.tsx │ └── withRemoteRender.test.tsx ├── model │ ├── default-service.test.ts │ └── dummy-service.test.ts └── utils.test.ts ├── tsconfig.json ├── tslint.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | dist 17 | 18 | # Dependency directories 19 | node_modules/ 20 | 21 | # Optional npm cache directory 22 | .npm 23 | 24 | # Optional eslint cache 25 | .eslintcache 26 | 27 | # Optional REPL history 28 | .node_repl_history 29 | 30 | # Output of 'npm pack' 31 | *.tgz 32 | 33 | # Yarn Integrity file 34 | .yarn-integrity 35 | 36 | # dotenv environment variables file 37 | .env 38 | 39 | .vscode 40 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode 3 | yarn-error.log 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /.storybook/addons.js: -------------------------------------------------------------------------------- 1 | import '@storybook/addon-options/register'; 2 | import './msg-logger/register'; -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { 2 | configure 3 | } from '@storybook/react'; 4 | import { 5 | setOptions 6 | } from '@storybook/addon-options'; 7 | 8 | setOptions({ 9 | name: 'React Remote Render', 10 | url: '#', 11 | goFullScreen: false, 12 | showLeftPanel: true, 13 | showDownPanel: true, 14 | downPanelInRight: true, 15 | }); 16 | 17 | function loadStories() { 18 | require('../stories'); 19 | // You can require as many stories as you need. 20 | } 21 | 22 | configure(loadStories, module); 23 | -------------------------------------------------------------------------------- /.storybook/msg-logger/constants.ts: -------------------------------------------------------------------------------- 1 | export const CHANNEL_ID = 'storybook/msg-logger'; 2 | export const PANEL_ID = 'storybook/msg-logger/panel'; -------------------------------------------------------------------------------- /.storybook/msg-logger/index.ts: -------------------------------------------------------------------------------- 1 | import addons from '@storybook/addons'; 2 | import { CHANNEL_ID } from './constants'; 3 | 4 | export const messageLogger = (kind) => (msg) => { 5 | const channel = addons.getChannel(); 6 | channel.emit(CHANNEL_ID, msg); 7 | } -------------------------------------------------------------------------------- /.storybook/msg-logger/register.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import addons from '@storybook/addons'; 3 | import { CHANNEL_ID, PANEL_ID } from './constants'; 4 | 5 | const styles: {[k:string]: React.CSSProperties} = { 6 | notesPanel: { 7 | margin: 10, 8 | fontFamily: 'Arial', 9 | fontSize: 14, 10 | color: '#444', 11 | width: '100%', 12 | overflow: 'auto', 13 | }, 14 | msgItem: { 15 | fontSize: 10 16 | } 17 | }; 18 | 19 | interface MessagesProps { 20 | channel: any; 21 | api: any; 22 | } 23 | 24 | class Messages extends React.Component { 25 | state: { messages: any[] } = { messages: [] }; 26 | private stopListeningOnStory?: Function 27 | 28 | onNewMessage = (msg) => { 29 | this.setState(({ messages }) => ({ 30 | messages: messages.concat(msg) 31 | })); 32 | } 33 | 34 | clearPanel = () => this.setState({ messages: [] }); 35 | 36 | componentDidMount() { 37 | const { channel, api } = this.props; 38 | channel.on(CHANNEL_ID, this.onNewMessage); 39 | this.stopListeningOnStory = api.onStory(this.clearPanel); 40 | } 41 | 42 | // This is some cleanup tasks when the Notes panel is unmounting. 43 | componentWillUnmount() { 44 | if (this.stopListeningOnStory) { 45 | this.stopListeningOnStory(); 46 | } 47 | 48 | const { channel, api } = this.props; 49 | channel.removeListener(CHANNEL_ID, this.onNewMessage); 50 | } 51 | 52 | render() { 53 | const { messages } = this.state; 54 | return ( 55 |
56 | {messages.map((msg, idx) => ( 57 |
{JSON.stringify(msg, null, 2)}
58 | ))} 59 |
60 | ); 61 | } 62 | 63 | } 64 | 65 | // Register the addon with a unique name. 66 | addons.register('storybook/msg-logger', (api) => { 67 | // Also need to set a unique name to the panel. 68 | addons.addPanel(PANEL_ID, { 69 | title: 'Transport Logger', 70 | render: () => ( 71 | 72 | ), 73 | }) 74 | }) -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = { 3 | module: { 4 | rules: [ 5 | { 6 | test: /\.tsx?$/, 7 | use: 'ts-loader', 8 | exclude: /node_modules/ 9 | }, 10 | // { 11 | // test: /\.css$/, 12 | // use: [ 13 | // { loader: "style-loader" }, 14 | // { loader: "css-loader" } 15 | // ] 16 | // } 17 | ] 18 | }, 19 | resolve: { 20 | extensions: [".tsx", ".ts", ".js"] 21 | }, 22 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Mariano Cortesi 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 | # react-remote-render 2 | 3 | A HOC that let’s you render your components elsewhere (in another iframe, 4 | browser, wherever over the wire).  5 | 6 | **It’s like a Portal, but more powerful.** A Portal can only render your component within the same browser window, or within the visibility scope of your running application. 7 | 8 | ## Demo 9 | 10 | **Storybook**: https://mcortesi.github.io/react-remote-render/ 11 | 12 | code in `stories/` folder 13 | 14 | ## Instalation 15 | 16 | With yarn: 17 | 18 | `yarn add react-remote-render` 19 | 20 | Or with npm: 21 | 22 | `npm install --save react-remote-render` 23 | 24 | ## Why? 25 | 26 | It’s not common, that’s true. And probably 90% of the times, you only need a portal. But there are some situations where you might need it. 27 | 28 | The original use case for it, was to render in another iframe, sibling of the app iframe. Why such a strange configuration? Imagine your application is to be embebbed in other pages with partial ownershipt of the page real estate; and imagine you want to open a modal, but modals occupy the whole viewport dimension, not just the part your iframe/app lives in. So, in order to solve this, you have another iframe controlled from outside that only renders modals and occupy the whole viewport. To communicate both iframes is why react-remote-render was born. The master iframe (the app), wants to render a component (the modal) in the slave iframe (the modal's iframe). Thanks to the react-remote-render abstraction, the app doesn't really need to know that it's rendering the modal outside. 29 | 30 | I guess, any other master-slave configuration can benefit from this component. You could control what another screen renders from the master application in the main screen. 31 | 32 | Still, I wouldn’t recommend this solution from a master-master configuration, that is, where both “nodes” are independent. If you need both app to communicate there, probably sending redux-action it’s a better way to handle it. 33 | 34 | ## Usage 35 | 36 | There are 3 parts: 37 | 38 | 1. `withRemoteRender()` higher order component to mark a component to be remotely rendered. So, it won’t render in place, it will only render on the remote space. 39 | 2. `RemoteRenderProvider` it provides a context for `withRemoteRender()` decorated components with the remote configuration. 40 | 3. `Renderer` responsible of actually rendering the remote components. 41 | 42 | Let’s see an example 43 | 44 | First, we decorate components that we want to be render outside: 45 | ```js 46 | 47 | const ShareModal = ({ text, onShare }) => (...); 48 | const RRShareModal = withRemoteRender({ name: 'ShareModal'})(ShareModal); 49 | 50 | const QuestionModal = ({ onAnswer, question }) => (...); 51 | const RRQuestionModal = withRemoteRender({ name: 'QuestionModal'})(QuestionModal); 52 | ``` 53 | 54 | Second, we wrap our app with the `RemoteRenderProvider` 55 | ```js 56 | // The transport is the one that sends & recieves messages between the remote components (Renderer) and the local components (RRQuestionModal & RRShareModal in our case). 57 | const iframeTransport = new IframeTransport(); 58 | 59 | ReactDOM.render( 60 | 61 | 62 | , 63 | document.getElementById('root') 64 | ); 65 | ``` 66 | 67 | Finally, in another app, another iframe, another place, you set the renderer. 68 | ```js 69 | const iframeTransport = new IframeTransport(); 70 | 71 | ReactDOM.render( 72 | , 79 | document.getElementById('root') 80 | ); 81 | ``` 82 | 83 | So, whenever you render a RRShareModal or RRQuestionModal within your app, it will be rendered as a child of `Renderer` wherever it might live. Lifecycle events as mount, unmount & update will be sent using the transport. 84 | 85 | Also, when within ShareModal & QuestionModal the function `onShare` & `onAnswer` are called. The call will actually happen in the origin (the main app), not in the slave (where the renderer lives). The only constraint there, is that parameters **MUST** be serializable. 86 | 87 | ## Implementing a Transport 88 | 89 | Useful transports are not included in the library. The reason is that the transport primarely depends on the use case, and might me something custom for each app. But any suggestions on transports that 90 | can be reused is welcome, and we can implement them. 91 | 92 | A transport is just a class that implements: 93 | 94 | ```js 95 | export type ClientMessageHandler = (msg: ClientMessage) => void; 96 | export type ServerMessageHandler = (msg: ServerMessage) => void; 97 | 98 | export interface Transport { 99 | sendClientMessage(msg: ClientMessage); 100 | sendServerMessage(msg: ServerMessage); 101 | onClientMessage(msgHandler: ClientMessageHandler); 102 | onServerMessage(msgHandler: ServerMessageHandler); 103 | } 104 | ``` 105 | 106 | where `CientMessage` & `ServerMessage` are: 107 | 108 | ```js 109 | export enum ClientMessageKind { 110 | Mount = 'Mount', 111 | Update = 'Update', 112 | Unmount = 'Unmount' 113 | } 114 | 115 | export type ClientMessage = MountMessage | UpdateMessage | UnmountMessage; 116 | export type ServerMessage = FunctionCallMessage; 117 | 118 | export interface FunctionCallMessage { 119 | id: number; 120 | functionKey: string; 121 | params: any[]; 122 | } 123 | 124 | export type PropsForTransport = { 125 | simpleProps: { [key: string]: any }; 126 | functionProps: string[]; 127 | }; 128 | 129 | export interface MountMessage { 130 | kind: ClientMessageKind.Mount; 131 | id: number; 132 | name: string; 133 | props: PropsForTransport; 134 | } 135 | 136 | export interface UpdateMessage { 137 | kind: ClientMessageKind.Update; 138 | id: number; 139 | props: PropsForTransport; 140 | } 141 | 142 | export interface UnmountMessage { 143 | kind: ClientMessageKind.Unmount; 144 | id: number; 145 | } 146 | ``` 147 | 148 | But you don't really have to know the details on the message to implement a transport, probably the implementation just need to serialize the messages (`JSON.stringifiy` will suffice) and send them over the wire. 149 | 150 | For example, the `DummyTransport` is use for the examples is: 151 | 152 | ```js 153 | class DummyTransport implements Transport { 154 | clientMessageHandlers: ClientMessageHandler[] = []; 155 | serverMessageHandlers: ServerMessageHandler[] = []; 156 | 157 | sendClientMessage(msg: ClientMessage) { 158 | const receievedMsg = this.mimicTransport(msg); 159 | this.clientMessageHandlers.forEach(handler => { 160 | handler(receievedMsg); 161 | }); 162 | } 163 | 164 | sendServerMessage(msg: ServerMessage) { 165 | const receievedMsg = this.mimicTransport(msg); 166 | this.serverMessageHandlers.forEach(handler => { 167 | handler(receievedMsg); 168 | }); 169 | } 170 | 171 | onClientMessage(msgHandler: (msg: ClientMessage) => void) { 172 | this.clientMessageHandlers.push(msgHandler); 173 | } 174 | onServerMessage(msgHandler: (msg: ServerMessage) => void) { 175 | this.serverMessageHandlers.push(msgHandler); 176 | } 177 | 178 | private mimicTransport(value: A): A { 179 | const asString = JSON.stringify(value); 180 | return JSON.parse(asString); 181 | } 182 | } 183 | ``` 184 | 185 | In a real app, `mimicTransport` would be replaced by an HTTP request or a `window.postMessage()` call. 186 | 187 | ## Contributing 188 | 189 | Common tasks: 190 | 191 | * Building: `yarn build` 192 | * Run tests: `yarn test` or `yarn test:watch` for watch mode 193 | * Run linter: `yarn lint` 194 | * Run test & lint w/coverage: `yarn test:prod` 195 | * Run storybook (examples here) `yarn storybook` 196 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-remote-render", 3 | "version": "0.1.1", 4 | "main": "dist/index.js", 5 | "types": "dist/index.d.ts", 6 | "author": "Mariano Cortesi ", 7 | "repository": { 8 | "url": "https://github.com/mcortesi/react-remote-render.git" 9 | }, 10 | "license": "MIT", 11 | "scripts": { 12 | "build": "tsc", 13 | "precommit": "lint-staged", 14 | "lint": "tslint --type-check -p . -t codeFrame 'src/**/*.ts'", 15 | "test": "jest", 16 | "test:watch": "jest --watch", 17 | "test:prod": "yarn lint && yarn test --coverage --no-cache", 18 | "prepublish": "yarn build", 19 | "storybook": "start-storybook -p 9001 -c .storybook", 20 | "deploy-storybook": "storybook-to-ghpages" 21 | }, 22 | "lint-staged": { 23 | "{src,test}/**/*.ts": [ 24 | "prettier -l" 25 | ] 26 | }, 27 | "jest": { 28 | "transform": { 29 | ".(ts|tsx)": "/node_modules/ts-jest/preprocessor.js" 30 | }, 31 | "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$", 32 | "moduleFileExtensions": [ 33 | "ts", 34 | "tsx", 35 | "js" 36 | ], 37 | "mapCoverage": true, 38 | "coveragePathIgnorePatterns": [ 39 | "/node_modules/", 40 | "/test/" 41 | ], 42 | "coverageThreshold": { 43 | "global": { 44 | "branches": 90, 45 | "functions": 95, 46 | "lines": 95, 47 | "statements": 95 48 | } 49 | } 50 | }, 51 | "dependencies": { 52 | "@types/invariant": "^2.2.29", 53 | "invariant": "^2.2.2" 54 | }, 55 | "peerDependencies": { 56 | "react": "^15.6.1 || ^16.0.0" 57 | }, 58 | "devDependencies": { 59 | "@storybook/addon-options": "^3.2.6", 60 | "@storybook/react": "^3.2.6", 61 | "@storybook/storybook-deployer": "^2.0.0", 62 | "@types/enzyme": "^2.8.7", 63 | "@types/jest": "^20.0.8", 64 | "@types/node": "^8.0.24", 65 | "@types/react": "^16.0.4", 66 | "enzyme": "^2.9.1", 67 | "husky": "^0.14.3", 68 | "jest": "^20.0.4", 69 | "lint-staged": "^4.1.3", 70 | "prettier": "^1.6.1", 71 | "react": "^15.6.1", 72 | "react-dom": "^15.6.1", 73 | "react-test-renderer": "^15.6.1", 74 | "rimraf": "^2.6.1", 75 | "ts-jest": "^20.0.10", 76 | "ts-loader": "^2.3.3", 77 | "tsc-watch": "^1.0.8", 78 | "tslint": "^5.7.0", 79 | "tslint-config-prettier": "^1.5.0", 80 | "tslint-config-standard": "^6.0.1", 81 | "typescript": "^2.4.2", 82 | "webpack": "^3.5.6", 83 | "webpack-dev-server": "^2.7.1" 84 | }, 85 | "storybook-deployer": { 86 | "gitUsername": "Mariano Cortesi", 87 | "gitEmail": "mcortesi@gmail.com", 88 | "commitMessage": "Deploy Storybook [skip ci]" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/components/RemoteRenderProvider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as PropTypes from 'prop-types'; 3 | import * as invariant from 'invariant'; 4 | import { DefaultRemoteRenderClient } from '../model/default-service'; 5 | import { RemoteRenderClient } from '../types/service'; 6 | import { Transport } from '../types/transport'; 7 | 8 | export interface RemoteRenderProviderProps { 9 | client?: RemoteRenderClient; 10 | transport?: Transport; 11 | } 12 | 13 | export default class RemoteRenderProvider extends React.Component< 14 | RemoteRenderProviderProps 15 | > { 16 | static childContextTypes = { 17 | client: PropTypes.object 18 | }; 19 | 20 | static propTypes = { 21 | client: PropTypes.object, 22 | transport: PropTypes.object, 23 | children: PropTypes.element.isRequired 24 | }; 25 | 26 | private client: RemoteRenderClient; 27 | 28 | constructor(props) { 29 | super(props); 30 | 31 | invariant( 32 | !(this.props.client && this.props.transport), 33 | "can't set transport & client props at the same time" 34 | ); 35 | invariant( 36 | this.props.client || this.props.transport, 37 | 'At least a transport or a client prop must be given' 38 | ); 39 | 40 | this.client = this.props.transport 41 | ? new DefaultRemoteRenderClient(this.props.transport) 42 | : this.props.client!; 43 | } 44 | 45 | getChildContext(): { client: RemoteRenderClient } { 46 | return { client: this.client }; 47 | } 48 | 49 | render() { 50 | return React.Children.only(this.props.children); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/components/Renderer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as invariant from 'invariant'; 3 | import { DefaultRemoteRenderServer } from '../model/default-service'; 4 | import { RemoteRenderServer, RemoteRenderHandler } from '../types/service'; 5 | import { Transport } from '../types/transport'; 6 | import { ObjMap } from '../types/base'; 7 | import { Props } from '../types/base'; 8 | import { RemoteRenderComponent } from './withRemoteRender'; 9 | 10 | export interface ComponentState { 11 | id: number; 12 | name: string; 13 | props: Props; 14 | } 15 | 16 | export interface RendererProps { 17 | components: RemoteRenderComponent[]; 18 | server?: RemoteRenderServer; 19 | transport?: Transport; 20 | } 21 | 22 | export interface RendererState { 23 | instances: ComponentState[]; 24 | } 25 | 26 | export default class Renderer extends React.PureComponent< 27 | RendererProps, 28 | RendererState 29 | > { 30 | state: RendererState = { instances: [] }; 31 | private server: RemoteRenderServer; 32 | private handler: RemoteRenderHandler; 33 | 34 | constructor(props: RendererProps) { 35 | super(props); 36 | 37 | this.handler = { 38 | onComponentMount: (id: number, name: string, props: Props) => { 39 | this.setState(prevState => ({ 40 | instances: prevState.instances.concat([{ id, name, props }]) 41 | })); 42 | }, 43 | 44 | onComponentUpdate: (id: number, props: Props) => { 45 | this.setState(prevState => ({ 46 | instances: prevState.instances.map(cState => { 47 | if (cState.id === id) { 48 | return { id, name: cState.name, props }; 49 | } else { 50 | return cState; 51 | } 52 | }) 53 | })); 54 | }, 55 | 56 | onComponentUnmount: (id: number) => { 57 | this.setState(prevState => ({ 58 | instances: prevState.instances.filter(cState => cState.id !== id) 59 | })); 60 | } 61 | }; 62 | 63 | invariant( 64 | !(props.server && props.transport), 65 | "Can't set server & transport at the same time" 66 | ); 67 | invariant( 68 | props.server || props.transport, 69 | 'either server or transport is required' 70 | ); 71 | this.server = props.transport 72 | ? new DefaultRemoteRenderServer(props.transport) 73 | : props.server as RemoteRenderServer; 74 | this.server.registerHandler(this.handler); 75 | } 76 | 77 | componentWillUnmount() { 78 | this.server.unregisterHandler(this.handler); 79 | } 80 | 81 | getComponent(name) { 82 | return this.props.components.find(c => c.externalName === name); 83 | } 84 | 85 | render() { 86 | return ( 87 |
88 | {this.state.instances.map(({ id, name, props }) => { 89 | const ExternalizedComponent = this.getComponent( 90 | name 91 | ) as RemoteRenderComponent; 92 | if (ExternalizedComponent) { 93 | const Component = ExternalizedComponent.WrappedComponent; 94 | return ( 95 | 99 | ); 100 | } else { 101 | return null; 102 | } 103 | })} 104 |
105 | ); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/components/withRemoteRender.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as PropTypes from 'prop-types'; 3 | import * as invariant from 'invariant'; 4 | import { Shape } from '../types/base'; 5 | import { RemoteRenderClient } from '../types/service'; 6 | import { mapObject } from '../utils'; 7 | 8 | export type CustomSerializers = { 9 | readonly [P in keyof T]?: { 10 | serialize: (value: T[P]) => any; 11 | deserialize: (value: any) => T[P]; 12 | } 13 | }; 14 | 15 | export type Options = { 16 | name?: string; 17 | customSerializers?: CustomSerializers; 18 | }; 19 | 20 | export type RemoteRenderComponent = React.ComponentClass & 21 | RemoteRenderComponentStatic; 22 | 23 | export interface RemoteRenderComponentStatic { 24 | WrappedComponent: React.ComponentType; 25 | externalName: string; 26 | serializeProps: (props: Props) => Shape; 27 | deserializeProps: (props: Shape) => Props; 28 | } 29 | 30 | /** 31 | * Creates an Remote Render Component. 32 | * This means the component won't render itself where it's mounted, but instead it will be rendered elsewhere in a . 33 | * The renderer is probably in a different iframe/frame/process/browser/etc. 34 | * 35 | * Some important considerations for this to work: 36 | * * All props must be "serializable" with JSON.stringify. if not, define pass customSerializers to make it so 37 | * * Props that are functions, will be replaced so to work "on the wire". They are restricted. All parameters they receive 38 | * MUST be serializable. Function return type must be *void*. 39 | * * Since children prop is not serializable, is not supported. So, only works with component with no children prop. 40 | * 41 | */ 42 | export default function withRemoteRender( 43 | options: Options = {} 44 | ): (Component: React.ComponentType) => RemoteRenderComponent { 45 | const serializer = 46 | options.customSerializers == null 47 | ? p => p 48 | : (props: OProps) => 49 | mapObject(props, (value, key) => { 50 | if (options.customSerializers![key]) { 51 | return options.customSerializers![key]!.serialize(value); 52 | } else { 53 | return value; 54 | } 55 | }); 56 | 57 | const deserializer = 58 | options.customSerializers == null 59 | ? p => p 60 | : (props: OProps) => 61 | mapObject(props, (value, key) => { 62 | if (options.customSerializers![key]) { 63 | return options.customSerializers![key]!.deserialize(value); 64 | } else { 65 | return value; 66 | } 67 | }); 68 | 69 | return (Component: React.ComponentType) => { 70 | const externalName = (options.name || 71 | Component.displayName || 72 | Component.name) as string; 73 | 74 | invariant(externalName, 'Need an external name for externalized component'); 75 | 76 | class ExternalizedComponent extends React.PureComponent { 77 | static WrappedComponent = Component; 78 | static externalName = externalName; 79 | 80 | static serializeProps = serializer; 81 | static deserializeProps = deserializer; 82 | 83 | static contextTypes = { client: PropTypes.object }; 84 | 85 | private client: RemoteRenderClient; 86 | private id: number; 87 | 88 | constructor(props, context) { 89 | super(props, context); 90 | 91 | this.client = context.client; 92 | 93 | invariant( 94 | this.client, 95 | "Could not find 'client' in context. Wrap the root component with " 96 | ); 97 | } 98 | 99 | componentDidMount() { 100 | this.id = this.client.mountComponent( 101 | externalName, 102 | serializer(this.props) 103 | ); 104 | } 105 | 106 | componentDidUpdate() { 107 | this.client.updateComponent(this.id, serializer(this.props)); 108 | } 109 | 110 | componentWillUnmount() { 111 | this.client.unmountComponent(this.id); 112 | } 113 | 114 | render() { 115 | return null; 116 | } 117 | } 118 | 119 | (ExternalizedComponent as React.ComponentClass< 120 | OProps 121 | >).displayName = `Externalized(${externalName})`; 122 | 123 | return ExternalizedComponent; 124 | }; 125 | } 126 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | default as RemoteRenderProvider 3 | } from './components/RemoteRenderProvider'; 4 | export { default as Renderer } from './components/Renderer'; 5 | export { 6 | default as withRemoteRender, 7 | RemoteRenderComponent 8 | } from './components/withRemoteRender'; 9 | export * from './model/default-service'; 10 | export * from './types/messages'; 11 | export * from './types/service'; 12 | export * from './types/transport'; 13 | export * from './types/base'; 14 | -------------------------------------------------------------------------------- /src/model/default-service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | RemoteRenderClient, 3 | RemoteRenderServer, 4 | RemoteRenderHandler 5 | } from '../types/service'; 6 | import { Props } from '../types/base'; 7 | import { Transport } from '../types/transport'; 8 | import { 9 | PropsForTransport, 10 | ClientMessageKind, 11 | ClientMessage 12 | } from '../types/messages'; 13 | import { fromPairs } from '../utils'; 14 | 15 | export class DefaultRemoteRenderClient implements RemoteRenderClient { 16 | private nextId = 0; 17 | private transport: Transport; 18 | private mountedComponents: Map> = new Map(); 19 | 20 | constructor(transport: Transport) { 21 | this.transport = transport; 22 | 23 | this.transport.onServerMessage(msg => { 24 | this.onRemoteFunctionCall(msg.id, msg.functionKey, msg.params); 25 | }); 26 | } 27 | 28 | mountComponent(name: string, props: Props): number { 29 | const id = this.nextId++; 30 | this.transport.sendClientMessage({ 31 | kind: ClientMessageKind.Mount, 32 | id, 33 | name, 34 | props: this.processProps(id, props) 35 | }); 36 | return id; 37 | } 38 | 39 | updateComponent(id: number, props: Props) { 40 | this.transport.sendClientMessage({ 41 | kind: ClientMessageKind.Update, 42 | id, 43 | props: this.processProps(id, props) 44 | }); 45 | } 46 | 47 | unmountComponent(id: number) { 48 | this.transport.sendClientMessage({ 49 | kind: ClientMessageKind.Unmount, 50 | id 51 | }); 52 | this.mountedComponents.delete(id); 53 | } 54 | 55 | private onRemoteFunctionCall(id: number, functionKey: string, params: any[]) { 56 | if ( 57 | this.mountedComponents.has(id) && 58 | this.mountedComponents.get(id)!.has(functionKey) 59 | ) { 60 | this.mountedComponents.get(id)!.get(functionKey)!(...params); 61 | } else { 62 | console.error( 63 | `Tried to call unmounted/unexistent function component:${id} fn:${functionKey}` 64 | ); 65 | } 66 | } 67 | 68 | private processProps(id: number, props: Props): PropsForTransport { 69 | const functionProps: string[] = []; 70 | const simpleProps: Props = {}; 71 | const savedFunctions: Map = new Map(); 72 | 73 | Object.keys(props).forEach(propKey => { 74 | if (typeof props[propKey] === 'function') { 75 | functionProps.push(propKey); 76 | savedFunctions.set(propKey, props[propKey]); 77 | } else { 78 | simpleProps[propKey] = props[propKey]; 79 | } 80 | }); 81 | 82 | this.mountedComponents.set(id, savedFunctions); 83 | 84 | return { simpleProps, functionProps }; 85 | } 86 | } 87 | 88 | export class DefaultRemoteRenderServer implements RemoteRenderServer { 89 | private handlers: RemoteRenderHandler[] = []; 90 | private transport: Transport; 91 | 92 | constructor(transport: Transport) { 93 | this.transport = transport; 94 | 95 | this.transport.onClientMessage(this.onClientMessage); 96 | } 97 | 98 | registerHandler(listener: RemoteRenderHandler) { 99 | this.handlers.push(listener); 100 | } 101 | 102 | unregisterHandler(listener: RemoteRenderHandler) { 103 | this.handlers = this.handlers.filter(handler => handler !== listener); 104 | } 105 | 106 | private tellHandlers(f: (h: RemoteRenderHandler) => void): void { 107 | for (const h of this.handlers) { 108 | try { 109 | f(h); 110 | } catch (err) { 111 | console.error(err); 112 | // continue 113 | } 114 | } 115 | } 116 | 117 | private onClientMessage = (msg: ClientMessage) => { 118 | switch (msg.kind) { 119 | case ClientMessageKind.Mount: { 120 | const parsedProps = this.processProps(msg.id, msg.props); 121 | this.tellHandlers(h => 122 | h.onComponentMount(msg.id, msg.name, parsedProps) 123 | ); 124 | break; 125 | } 126 | case ClientMessageKind.Update: { 127 | const parsedProps = this.processProps(msg.id, msg.props); 128 | this.tellHandlers(h => h.onComponentUpdate(msg.id, parsedProps)); 129 | break; 130 | } 131 | case ClientMessageKind.Unmount: { 132 | this.tellHandlers(h => h.onComponentUnmount(msg.id)); 133 | break; 134 | } 135 | default: { 136 | throw new Error(`unknown message kind ${msg}`); 137 | } 138 | } 139 | }; 140 | 141 | private processProps(id: number, props: PropsForTransport): Props { 142 | const functionProxy = functionKey => (...params) => 143 | this.transport.sendServerMessage({ id, functionKey, params }); 144 | const functionProps = fromPairs( 145 | props.functionProps.map( 146 | key => [key, functionProxy(key)] as [string, Function] 147 | ) 148 | ); 149 | 150 | return Object.assign({}, props.simpleProps, functionProps); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/model/dummy-service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | RemoteRenderClient, 3 | RemoteRenderServer, 4 | RemoteRenderHandler 5 | } from '../types/service'; 6 | import { Props } from '../types/base'; 7 | 8 | export default class DummyRemoteRenderClient 9 | implements RemoteRenderClient, RemoteRenderServer { 10 | private handlers: RemoteRenderHandler[] = []; 11 | private nextId = 0; 12 | 13 | hasHandlers() { 14 | return this.handlers.length > 0; 15 | } 16 | 17 | registerHandler(listener: RemoteRenderHandler) { 18 | this.handlers.push(listener); 19 | } 20 | 21 | unregisterHandler(listener: RemoteRenderHandler) { 22 | this.handlers = this.handlers.filter(handler => handler !== listener); 23 | } 24 | 25 | mountComponent(name: string, props: Props): number { 26 | const id = this.nextId++; 27 | this.tellHandlers(h => { 28 | h.onComponentMount(id, name, props); 29 | }); 30 | return id; 31 | } 32 | 33 | updateComponent(id: number, props: Props) { 34 | this.tellHandlers(h => { 35 | h.onComponentUpdate(id, props); 36 | }); 37 | } 38 | 39 | unmountComponent(id: number) { 40 | this.tellHandlers(h => { 41 | h.onComponentUnmount(id); 42 | }); 43 | } 44 | 45 | private tellHandlers(f: (h: RemoteRenderHandler) => void): void { 46 | for (const h of this.handlers) { 47 | try { 48 | f(h); 49 | } catch (err) { 50 | console.error(err); 51 | // continue 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/model/dummy-transport.ts: -------------------------------------------------------------------------------- 1 | import { ClientMessage, ServerMessage } from '../types/messages'; 2 | import { 3 | Transport, 4 | ClientMessageHandler, 5 | ServerMessageHandler 6 | } from '../types/transport'; 7 | 8 | export default class DummyTransport implements Transport { 9 | clientMessageHandlers: ClientMessageHandler[] = []; 10 | serverMessageHandlers: ServerMessageHandler[] = []; 11 | 12 | sendClientMessage(msg: ClientMessage) { 13 | const receievedMsg = this.mimicTransport(msg); 14 | this.clientMessageHandlers.forEach(handler => { 15 | handler(receievedMsg); 16 | }); 17 | } 18 | 19 | sendServerMessage(msg: ServerMessage) { 20 | const receievedMsg = this.mimicTransport(msg); 21 | this.serverMessageHandlers.forEach(handler => { 22 | handler(receievedMsg); 23 | }); 24 | } 25 | 26 | onClientMessage(msgHandler: (msg: ClientMessage) => void) { 27 | this.clientMessageHandlers.push(msgHandler); 28 | } 29 | onServerMessage(msgHandler: (msg: ServerMessage) => void) { 30 | this.serverMessageHandlers.push(msgHandler); 31 | } 32 | 33 | private mimicTransport
(value: A): A { 34 | const asString = JSON.stringify(value); 35 | return JSON.parse(asString); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/types/base.ts: -------------------------------------------------------------------------------- 1 | export interface Props { 2 | [name: string]: any; 3 | } 4 | 5 | export type Shape = { [k in keyof T]: B }; 6 | 7 | export type ObjMap = { 8 | [k: string]: A; 9 | }; 10 | -------------------------------------------------------------------------------- /src/types/messages.ts: -------------------------------------------------------------------------------- 1 | export enum ClientMessageKind { 2 | Mount = 'Mount', 3 | Update = 'Update', 4 | Unmount = 'Unmount' 5 | } 6 | 7 | export type ClientMessage = MountMessage | UpdateMessage | UnmountMessage; 8 | export type ServerMessage = FunctionCallMessage; 9 | 10 | export interface FunctionCallMessage { 11 | id: number; 12 | functionKey: string; 13 | params: any[]; 14 | } 15 | 16 | export type PropsForTransport = { 17 | simpleProps: { [key: string]: any }; 18 | functionProps: string[]; 19 | }; 20 | 21 | export interface MountMessage { 22 | kind: ClientMessageKind.Mount; 23 | id: number; 24 | name: string; 25 | props: PropsForTransport; 26 | } 27 | 28 | export interface UpdateMessage { 29 | kind: ClientMessageKind.Update; 30 | id: number; 31 | props: PropsForTransport; 32 | } 33 | 34 | export interface UnmountMessage { 35 | kind: ClientMessageKind.Unmount; 36 | id: number; 37 | } 38 | -------------------------------------------------------------------------------- /src/types/service.ts: -------------------------------------------------------------------------------- 1 | import { Props } from './base'; 2 | 3 | export interface RemoteRenderClient { 4 | mountComponent(name: string, props: Props): number; 5 | updateComponent(id: number, props: Props); 6 | unmountComponent(id: number); 7 | } 8 | 9 | export interface RemoteRenderServer { 10 | registerHandler(listener: RemoteRenderHandler); 11 | unregisterHandler(listener: RemoteRenderHandler); 12 | } 13 | 14 | export interface RemoteRenderHandler { 15 | onComponentMount(id: number, name: string, props: Props); 16 | onComponentUpdate(id: number, props: Props); 17 | onComponentUnmount(id: number); 18 | } 19 | -------------------------------------------------------------------------------- /src/types/transport.ts: -------------------------------------------------------------------------------- 1 | import { ClientMessage, ServerMessage } from './messages'; 2 | 3 | export type ClientMessageHandler = (msg: ClientMessage) => void; 4 | export type ServerMessageHandler = (msg: ServerMessage) => void; 5 | 6 | export interface Transport { 7 | sendClientMessage(msg: ClientMessage); 8 | sendServerMessage(msg: ServerMessage); 9 | onClientMessage(msgHandler: ClientMessageHandler); 10 | onServerMessage(msgHandler: ServerMessageHandler); 11 | } 12 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { Shape, ObjMap } from './types/base'; 2 | 3 | export function fromPairs(arr: Array<[string, A]>): ObjMap { 4 | return arr.reduce((acc, [key, val]) => { 5 | acc[key] = val; 6 | return acc; 7 | }, {}); 8 | } 9 | 10 | export function mapObject>( 11 | obj: C, 12 | mapper: (val: A, key: keyof C) => B 13 | ): Shape { 14 | return fromPairs( 15 | Object.keys(obj).map( 16 | key => [key, mapper(obj[key] as A, key)] as [string, B] 17 | ) 18 | ) as any; 19 | } 20 | -------------------------------------------------------------------------------- /stories/defaultServiceStory.tsx: -------------------------------------------------------------------------------- 1 | import { storiesOf } from '@storybook/react'; 2 | import * as React from 'react'; 3 | import withRemoteRender, { 4 | RemoteRenderComponent 5 | } from '../src/components/withRemoteRender'; 6 | import { TestScenario } from './utils'; 7 | 8 | const stories = storiesOf('With DefaultService', module); 9 | 10 | type ParagraphProps = { text: string }; 11 | const Paragraph: React.SFC = ({ text }) =>

{text}

; 12 | 13 | type ButtonProps = { onClick: Function; n: number }; 14 | const Button: React.SFC = ({ onClick, n }) => ( 15 | 16 | ); 17 | 18 | const RRParagraph = withRemoteRender({ name: 'Paragraph' })( 19 | Paragraph 20 | ); 21 | const RRButton = withRemoteRender({ name: 'Button' })(Button); 22 | 23 | class ButtonText extends React.Component { 24 | state: { clicks: number } = { clicks: 0 }; 25 | 26 | incrementClicks = n => { 27 | this.setState({ clicks: this.state.clicks + n }); 28 | }; 29 | 30 | render() { 31 | return ( 32 |
33 | 34 | 35 | 36 |
37 | ); 38 | } 39 | } 40 | 41 | stories.add('Simple Example', () => ( 42 | 43 |
44 | 45 | 46 |
47 |
48 | )); 49 | 50 | stories.add('Calling Action in externalized components', () => ( 51 | 52 | 53 | 54 | )); 55 | -------------------------------------------------------------------------------- /stories/dummyServiceStory.tsx: -------------------------------------------------------------------------------- 1 | import { storiesOf } from '@storybook/react'; 2 | import * as React from 'react'; 3 | import RemoteRenderProvider from '../src/components/RemoteRenderProvider'; 4 | import withRemoteRender, { RemoteRenderComponent } from '../src/components/withRemoteRender'; 5 | import Renderer from '../src/components/Renderer'; 6 | import DummyService from '../src/model/dummy-service'; 7 | 8 | const stories = storiesOf('With Dummy Service', module); 9 | 10 | interface DummyProxyTestProps { 11 | externalizedComponents: RemoteRenderComponent[] 12 | } 13 | class DummyProxyTest extends React.Component { 14 | state: { proxy: DummyService }; 15 | 16 | constructor(props) { 17 | super(props); 18 | 19 | this.state = { 20 | proxy: new DummyService() 21 | } 22 | } 23 | 24 | render() { 25 | return ( 26 |
27 |
28 |

The main flow (provider)

29 | 30 | {this.props.children} 31 | 32 |
33 |
34 |

The Renderer

35 | 39 |
40 |
41 | ); 42 | } 43 | } 44 | 45 | const Paragraph: React.SFC<{ text: string }> = ({ text }) => (

{text}

); 46 | const ExternalizedParagraph = withRemoteRender<{ text: string }>({ name: 'Paragraph'})(Paragraph); 47 | 48 | const Button: React.SFC<{ onClick: Function }> = ({ onClick }) => ( 49 | 50 | ); 51 | const ExternalizedButton = withRemoteRender<{ onClick: Function }>({ name: 'Button'})(Button); 52 | 53 | class ButtonText extends React.Component { 54 | state: { clicks: number } = { clicks: 0 } 55 | 56 | incrementClicks = () => { 57 | this.setState({ clicks: this.state.clicks + 1 }); 58 | } 59 | 60 | render() { 61 | return ( 62 |
63 | 64 | 65 |
66 | ); 67 | } 68 | } 69 | 70 | stories.add('Simple Example', () => ( 71 | 72 | 73 | 74 | 75 | )); 76 | 77 | stories.add('Calling Action in externalized components', () => ( 78 | 79 | 80 | 81 | )); 82 | -------------------------------------------------------------------------------- /stories/index.ts: -------------------------------------------------------------------------------- 1 | import './defaultServiceStory'; 2 | // import './dummyServiceStory'; -------------------------------------------------------------------------------- /stories/utils.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { messageLogger } from '../.storybook/msg-logger'; 3 | 4 | import RemoteRenderProvider from '../src/components/RemoteRenderProvider'; 5 | import withRemoteRender, { RemoteRenderComponent } from '../src/components/withRemoteRender'; 6 | import Renderer from '../src/components/Renderer'; 7 | import { DefaultRemoteRenderClient, DefaultRemoteRenderServer } from '../src/model/default-service'; 8 | import DummyTransport from '../src/model/dummy-transport'; 9 | 10 | export const Section = ({ title, children, style = {} }) => ( 11 |
12 |

{title}

13 |
14 | {children} 15 |
16 |
17 | ) 18 | 19 | export interface TestScenarioProps { 20 | supportedComponents: RemoteRenderComponent[] 21 | } 22 | 23 | export class TestScenario extends React.Component { 24 | state: { proxy: DefaultRemoteRenderClient, proxyServer: DefaultRemoteRenderServer }; 25 | 26 | constructor(props) { 27 | super(props); 28 | 29 | const transport = new DummyTransport(); 30 | 31 | transport.onClientMessage(messageLogger('client-msg')); 32 | transport.onServerMessage(messageLogger('server-msg')); 33 | 34 | this.state = { 35 | proxy: new DefaultRemoteRenderClient(transport), 36 | proxyServer: new DefaultRemoteRenderServer(transport) 37 | } 38 | } 39 | 40 | render() { 41 | return ( 42 |
43 |
44 | 45 | {this.props.children} 46 | 47 |
48 |
49 | 53 |
54 |
55 | ); 56 | } 57 | } -------------------------------------------------------------------------------- /test/components/RemoteRenderProvider.test.tsx: -------------------------------------------------------------------------------- 1 | import RemoteRenderProvider from '../../src/components/RemoteRenderProvider'; 2 | import DummyService from '../../src/model/dummy-service'; 3 | import Transport from '../../src/model/dummy-transport'; 4 | import { mount } from 'enzyme'; 5 | import * as React from 'react'; 6 | 7 | // silent console.error 8 | const errorSpy = jest.spyOn(global.console, 'error').mockImplementation(() => { 9 | /* do nothing */ 10 | }); 11 | 12 | test('fails if neither transport or client is passed', () => { 13 | expect(() => 14 | mount( 15 | 16 |
17 | 18 | ) 19 | ).toThrow(); 20 | }); 21 | 22 | test('accepts a transport instead of client', () => { 23 | mount( 24 | 25 |
26 | 27 | ); 28 | }); 29 | -------------------------------------------------------------------------------- /test/components/Renderer.test.tsx: -------------------------------------------------------------------------------- 1 | import withRemoteRender from '../../src/components/withRemoteRender'; 2 | import Renderer from '../../src/components/Renderer'; 3 | import DummyService from '../../src/model/dummy-service'; 4 | import { mount, ReactWrapper } from 'enzyme'; 5 | import * as React from 'react'; 6 | 7 | interface MessageProps { 8 | value: string; 9 | } 10 | const Message: React.StatelessComponent = ({ value }) => ( 11 |
{value}
12 | ); 13 | const EMessage = withRemoteRender()(Message); 14 | 15 | let client: DummyService; 16 | let c: ReactWrapper; 17 | 18 | beforeEach(() => { 19 | client = new DummyService(); 20 | c = mount(); 21 | }); 22 | 23 | test('renders on client message', () => { 24 | const mId = client.mountComponent(EMessage.externalName, { 25 | value: 'hello World' 26 | }); 27 | 28 | expect(c.find(Message)).toHaveLength(1); 29 | expect(c.find(Message).html()).toBe('
hello World
'); 30 | }); 31 | 32 | test('update component on componentUpdate', () => { 33 | const mId = client.mountComponent(EMessage.externalName, { 34 | value: 'hello World' 35 | }); 36 | client.updateComponent(mId, { value: 'Bye Bye' }); 37 | 38 | expect(c.find(Message)).toHaveLength(1); 39 | expect(c.find(Message).html()).toBe('
Bye Bye
'); 40 | }); 41 | 42 | test('unmounts component on componentUnmount', () => { 43 | const mId = client.mountComponent(EMessage.externalName, { 44 | value: 'hello World' 45 | }); 46 | client.unmountComponent(mId); 47 | 48 | expect(c.find(Message)).toHaveLength(0); 49 | }); 50 | 51 | test("will do nothing is doesn't recognize the componentId", () => { 52 | const mId = client.mountComponent(EMessage.externalName, { 53 | value: 'hello World' 54 | }); 55 | 56 | client.updateComponent(45, { value: 'Bye Bye' }); 57 | client.unmountComponent(33); 58 | 59 | expect(c.html()).toBe('
hello World
'); 60 | }); 61 | 62 | test("will render nothing if doesn't recognize the component type", () => { 63 | const mId = client.mountComponent(EMessage.externalName, { 64 | value: 'hello World' 65 | }); 66 | 67 | expect(c.html()).toBe('
hello World
'); 68 | 69 | c.setProps({ 70 | server: client, 71 | components: [] 72 | }); 73 | 74 | expect(c.html()).toBe('
'); 75 | }); 76 | 77 | test('stack components one after the other', () => { 78 | const mId = client.mountComponent(EMessage.externalName, { 79 | value: 'hello World' 80 | }); 81 | const m2Id = client.mountComponent(EMessage.externalName, { 82 | value: 'La muerte fue y sera una porqueria' 83 | }); 84 | 85 | expect(c.find(Message)).toHaveLength(2); 86 | 87 | client.unmountComponent(mId); 88 | expect(c.find(Message)).toHaveLength(1); 89 | 90 | client.unmountComponent(m2Id); 91 | expect(c.find(Message)).toHaveLength(0); 92 | }); 93 | 94 | test('unregister from client on unmount', () => { 95 | c.unmount(); 96 | expect(client.hasHandlers()).toBeFalsy(); 97 | }); 98 | -------------------------------------------------------------------------------- /test/components/withRemoteRender.test.tsx: -------------------------------------------------------------------------------- 1 | import withRemoteRender from '../../src/components/withRemoteRender'; 2 | import RemoteRenderProvider from '../../src/components/RemoteRenderProvider'; 3 | import DummyService from '../../src/model/dummy-service'; 4 | import { mount } from 'enzyme'; 5 | import * as React from 'react'; 6 | 7 | interface Props { 8 | title: string; 9 | onClick: () => void; 10 | } 11 | const Component: React.StatelessComponent = ({ title, onClick }) => ( 12 |
{title}
13 | ); 14 | 15 | let client: DummyService; 16 | let handler; 17 | beforeEach(() => { 18 | client = new DummyService(); 19 | handler = { 20 | onComponentMount: jest.fn(), 21 | onComponentUnmount: jest.fn(), 22 | onComponentUpdate: jest.fn() 23 | }; 24 | 25 | client.registerHandler(handler); 26 | }); 27 | 28 | test('does remote render', () => { 29 | const EComponent = withRemoteRender()(Component); 30 | 31 | const onClick = jest.fn(); 32 | const c = mount(, { 33 | context: { client } 34 | }); 35 | 36 | expect(c.isEmptyRender()).toBeTruthy(); 37 | expect(handler.onComponentMount).toHaveBeenCalledTimes(1); 38 | 39 | c.setProps({ title: 'title changed', onClick: onClick }); 40 | expect(handler.onComponentUpdate).toHaveBeenCalledTimes(1); 41 | 42 | // same props, no update 43 | c.setProps({ title: 'title changed', onClick: onClick }); 44 | expect(handler.onComponentUpdate).toHaveBeenCalledTimes(1); 45 | 46 | c.unmount(); 47 | expect(handler.onComponentUnmount).toHaveBeenCalledTimes(1); 48 | }); 49 | 50 | test('uses custom serializers', () => { 51 | const EComponent = withRemoteRender({ 52 | customSerializers: { 53 | title: { 54 | serialize: str => str.toUpperCase(), 55 | deserialize: str => str.toLowerCase() 56 | } 57 | } 58 | })(Component); 59 | 60 | const onClick = jest.fn(); 61 | const c = mount(, { 62 | context: { client } 63 | }); 64 | 65 | expect(handler.onComponentMount.mock.calls[0][2].title).toBe('HEY JO'); 66 | 67 | expect(EComponent.deserializeProps({ title: 'Aloha', onClick })).toEqual({ 68 | title: 'aloha', 69 | onClick 70 | }); 71 | }); 72 | 73 | test('using RemoteRenderProvider', () => { 74 | const EComponent = withRemoteRender()(Component); 75 | 76 | const onClick = jest.fn(); 77 | const c = mount( 78 | 79 | 80 | 81 | ); 82 | 83 | expect(handler.onComponentMount).toHaveBeenCalledTimes(1); 84 | }); 85 | -------------------------------------------------------------------------------- /test/model/default-service.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DefaultRemoteRenderClient, 3 | DefaultRemoteRenderServer 4 | } from '../../src/model/default-service'; 5 | import DummyTransport from '../../src/model/dummy-transport'; 6 | import { RemoteRenderHandler } from '../../src/types/service'; 7 | 8 | const errorSpy = jest.spyOn(global.console, 'error').mockImplementation(() => { 9 | /* do nothing */ 10 | }); 11 | 12 | let client: DefaultRemoteRenderClient; 13 | let server: DefaultRemoteRenderServer; 14 | let handler: { 15 | onComponentMount: jest.Mock<{}>; 16 | onComponentUnmount: jest.Mock<{}>; 17 | onComponentUpdate: jest.Mock<{}>; 18 | }; 19 | 20 | beforeEach(() => { 21 | errorSpy.mockClear(); 22 | 23 | const transport = new DummyTransport(); 24 | client = new DefaultRemoteRenderClient(transport); 25 | server = new DefaultRemoteRenderServer(transport); 26 | 27 | handler = { 28 | onComponentMount: jest.fn(), 29 | onComponentUnmount: jest.fn(), 30 | onComponentUpdate: jest.fn() 31 | }; 32 | 33 | server.registerHandler(handler); 34 | }); 35 | 36 | test('it pass along simple props', () => { 37 | const id = client.mountComponent('A', { 38 | num: 1, 39 | str: 'hola', 40 | arr: [1, 2], 41 | obj: { a: 2, b: [3, 4] } 42 | }); 43 | client.updateComponent(id, { b: 5, c: 10 }); 44 | client.unmountComponent(id); 45 | 46 | expect(handler.onComponentMount).toHaveBeenCalledTimes(1); 47 | expect(handler.onComponentMount).toBeCalledWith(id, 'A', { 48 | num: 1, 49 | str: 'hola', 50 | arr: [1, 2], 51 | obj: { a: 2, b: [3, 4] } 52 | }); 53 | expect(handler.onComponentUpdate).toBeCalledWith(id, { b: 5, c: 10 }); 54 | expect(handler.onComponentUnmount).toBeCalledWith(id); 55 | }); 56 | 57 | test('prop functions can be called over the wire', () => { 58 | const f = jest.fn(); 59 | 60 | client.mountComponent('A', { f }); 61 | 62 | const passedF = handler.onComponentMount.mock.calls[0][2].f; 63 | 64 | expect(passedF).not.toBe(f); 65 | 66 | passedF('a', 1, [2, 3]); 67 | expect(f).toHaveBeenLastCalledWith('a', 1, [2, 3]); 68 | 69 | passedF({ a: 3, b: 5 }); 70 | expect(f).toHaveBeenLastCalledWith({ a: 3, b: 5 }); 71 | }); 72 | 73 | test("nested function props won't work", () => { 74 | const f = jest.fn(); 75 | 76 | client.mountComponent('A', { nested: { f } }); 77 | 78 | const passedF = handler.onComponentMount.mock.calls[0][2].nested.f; 79 | 80 | expect(typeof passedF).not.toBe('function'); 81 | }); 82 | 83 | test('continues after handler error', () => { 84 | const failingHandler = { 85 | onComponentMount: jest.fn(() => { 86 | throw new Error('induced error'); 87 | }), 88 | onUnmountComponent: jest.fn(() => { 89 | throw new Error('induced error'); 90 | }), 91 | onUpdateComponent: jest.fn(() => { 92 | throw new Error('induced error'); 93 | }) 94 | }; 95 | server.registerHandler(failingHandler); 96 | 97 | const id = client.mountComponent('A', { a: 1, b: 2 }); 98 | client.updateComponent(id, { a: 2, b: 2 }); 99 | client.unmountComponent(id); 100 | 101 | expect(errorSpy).toHaveBeenCalledTimes(3); 102 | }); 103 | 104 | test('handle & log when trying to call old prop functions', () => { 105 | const f = jest.fn(); 106 | const id = client.mountComponent('A', { f }); 107 | const passedF = handler.onComponentMount.mock.calls[0][2].f; 108 | 109 | passedF('a', 1, [2, 3]); 110 | expect(f).toHaveBeenCalledTimes(1); 111 | 112 | client.updateComponent(id, { x: 'something else' }); 113 | 114 | passedF('aa'); 115 | expect(errorSpy).toHaveBeenCalledTimes(1); 116 | expect(f).toHaveBeenCalledTimes(1); 117 | }); 118 | 119 | test('handler unregisters', () => { 120 | client.mountComponent('A', { a: 1 }); 121 | expect(handler.onComponentMount).toHaveBeenCalledTimes(1); 122 | server.unregisterHandler(handler); 123 | 124 | client.mountComponent('B', { a: 1 }); 125 | expect(handler.onComponentMount).toHaveBeenCalledTimes(1); 126 | }); 127 | -------------------------------------------------------------------------------- /test/model/dummy-service.test.ts: -------------------------------------------------------------------------------- 1 | import DummyService from '../../src/model/dummy-service'; 2 | 3 | const errorSpy = jest.spyOn(global.console, 'error').mockImplementation(() => { 4 | /* do nothing */ 5 | }); 6 | 7 | test('calls handler', () => { 8 | const srv = new DummyService(); 9 | const handler = { 10 | onComponentMount: jest.fn(), 11 | onComponentUnmount: jest.fn(), 12 | onComponentUpdate: jest.fn() 13 | }; 14 | srv.registerHandler(handler); 15 | 16 | const id = srv.mountComponent('A', { a: 1, b: 2 }); 17 | srv.updateComponent(id, { a: 2, b: 2 }); 18 | srv.updateComponent(id, { a: 3, b: 2 }); 19 | srv.unmountComponent(id); 20 | 21 | expect(handler.onComponentMount).toHaveBeenCalledTimes(1); 22 | expect(handler.onComponentMount).toHaveBeenCalledWith(0, 'A', { a: 1, b: 2 }); 23 | 24 | expect(handler.onComponentUpdate).toHaveBeenCalledTimes(2); 25 | expect(handler.onComponentUpdate).toHaveBeenCalledWith(0, { a: 2, b: 2 }); 26 | expect(handler.onComponentUpdate).toHaveBeenCalledWith(0, { a: 3, b: 2 }); 27 | 28 | expect(handler.onComponentUnmount).toHaveBeenCalledTimes(1); 29 | expect(handler.onComponentUnmount).toHaveBeenCalledWith(0); 30 | }); 31 | 32 | test('calls ALL handlers', () => { 33 | const srv = new DummyService(); 34 | const createHandler = () => ({ 35 | onComponentMount: jest.fn(), 36 | onComponentUnmount: jest.fn(), 37 | onComponentUpdate: jest.fn() 38 | }); 39 | 40 | const h1 = createHandler(); 41 | const h2 = createHandler(); 42 | srv.registerHandler(h1); 43 | srv.registerHandler(h2); 44 | 45 | const id = srv.mountComponent('A', { a: 1, b: 2 }); 46 | srv.updateComponent(id, { a: 2, b: 2 }); 47 | srv.unmountComponent(id); 48 | 49 | expect(h1.onComponentMount).toHaveBeenCalledTimes(1); 50 | expect(h1.onComponentUpdate).toHaveBeenCalledTimes(1); 51 | expect(h1.onComponentUnmount).toHaveBeenCalledTimes(1); 52 | expect(h2.onComponentMount).toHaveBeenCalledTimes(1); 53 | expect(h2.onComponentUpdate).toHaveBeenCalledTimes(1); 54 | expect(h2.onComponentUnmount).toHaveBeenCalledTimes(1); 55 | }); 56 | 57 | test('continues after handler error', () => { 58 | const srv = new DummyService(); 59 | const failingHandler = { 60 | onComponentMount: jest.fn(() => { 61 | throw new Error('induced error'); 62 | }), 63 | onComponentUnmount: jest.fn(() => { 64 | throw new Error('induced error'); 65 | }), 66 | onComponentUpdate: jest.fn(() => { 67 | throw new Error('induced error'); 68 | }) 69 | }; 70 | const normalHandler = { 71 | onComponentMount: jest.fn(), 72 | onComponentUnmount: jest.fn(), 73 | onComponentUpdate: jest.fn() 74 | }; 75 | 76 | srv.registerHandler(failingHandler); 77 | srv.registerHandler(normalHandler); 78 | 79 | const id = srv.mountComponent('A', { a: 1, b: 2 }); 80 | srv.updateComponent(id, { a: 2, b: 2 }); 81 | srv.unmountComponent(id); 82 | 83 | expect(errorSpy).toHaveBeenCalledTimes(3); 84 | }); 85 | -------------------------------------------------------------------------------- /test/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { fromPairs, mapObject } from '../src/utils'; 2 | 3 | describe('fromPairs()', () => { 4 | it('should create object from pairs', () => { 5 | expect(fromPairs([['a', 1], ['b', 'hola']])).toEqual({ 6 | a: 1, 7 | b: 'hola' 8 | }); 9 | }); 10 | }); 11 | 12 | describe('mapObject', () => { 13 | it('should map object values', () => { 14 | expect(mapObject({ a: 1, b: 2 }, (x: number) => 2 * x)).toEqual({ 15 | a: 2, 16 | b: 4 17 | }); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "ES5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */ 5 | "module": "commonjs", /* Specify module code generation: 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 6 | "outDir": "dist", /* Redirect output structure to the directory. */ 7 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 8 | // "rootDir": "src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 9 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 10 | "sourceMap": true, /* Generates corresponding '.map' file. */ 11 | "jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 12 | "lib": ["dom", "dom.iterable", "es2015", "scripthost"], /* Specify library files to be included in the compilation: */ 13 | // "allowJs": true, /* Allow javascript files to be compiled. */ 14 | // "checkJs": true, /* Report errors in .js files. */ 15 | // "outFile": "./", /* Concatenate and emit output to single file. */ 16 | // "removeComments": true, /* Do not emit comments to output. */ 17 | // "noEmit": true, /* Do not emit outputs. */ 18 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 19 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 20 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 21 | /* Strict Type-Checking Options */ 22 | // "strict": true /* Enable all strict type-checking options. */ 23 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 24 | "strictNullChecks": true, /* Enable strict null checks. */ 25 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 26 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 27 | /* Additional Checks */ 28 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 29 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 30 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 31 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 32 | /* Module Resolution Options */ 33 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 34 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 35 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 36 | // "typeRoots": [], /* List of folders to include type definitions from. */ 37 | // "types": [], /* Type declaration files to be included in compilation. */ 38 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 39 | /* Source Map Options */ 40 | // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 41 | // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */ 42 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 43 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 44 | /* Experimental Options */ 45 | "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */ 46 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 47 | }, 48 | "include": [ 49 | "src" 50 | ], 51 | "compileOnSave": true 52 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint-config-standard", 4 | "tslint-config-prettier" 5 | ] 6 | } --------------------------------------------------------------------------------