├── .gitignore ├── example ├── .npmignore ├── index.html ├── TestComponent.tsx ├── tsconfig.json ├── LayoutApp │ ├── Layout.tsx │ ├── Feature1.tsx │ └── Feature2.tsx ├── package.json └── index.tsx ├── .npmignore ├── test └── temp.test.tsx ├── src ├── index.tsx ├── RenderArea.tsx ├── Content.tsx └── AreaContext.tsx ├── tsconfig.json ├── LICENSE ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist 4 | -------------------------------------------------------------------------------- /example/.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | example 3 | test 4 | .cache 5 | 6 | 7 | -------------------------------------------------------------------------------- /test/temp.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import { AreaProvider } from '../src'; 4 | 5 | describe('it', () => { 6 | it('renders without crashing', () => { 7 | const div = document.createElement('div'); 8 | ReactDOM.render(, div); 9 | ReactDOM.unmountComponentAtNode(div); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Playground 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /example/TestComponent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useEffect } from 'react'; 3 | 4 | let globalI = 0; 5 | 6 | export function TestComponent({ name, children }) { 7 | const i = globalI++; 8 | console.log('rendering', name, i); 9 | useEffect(() => { 10 | console.log('mounting', name, i); 11 | return () => { 12 | console.log('unmounting', name, i); 13 | }; 14 | }, []); 15 | return
{children}
; 16 | } 17 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AreaProvider } from './AreaContext'; 3 | import { RenderArea } from './RenderArea'; 4 | import { Content } from './Content'; 5 | 6 | export { AreaProvider, RenderArea, Content }; 7 | 8 | export function createRenderingPair(areaId: string) { 9 | const pair = {} as { RenderArea: React.FC; Content: React.FC }; 10 | pair.RenderArea = (props) => ; 11 | pair.Content = ({ children }) => {children}; 12 | return pair; 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "compilerOptions": { 4 | "target": "esnext", 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "strict": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "noImplicitReturns": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "moduleResolution": "node", 16 | "baseUrl": "./", 17 | "jsx": "react", 18 | "esModuleInterop": true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": false, 4 | "target": "es5", 5 | "module": "commonjs", 6 | "jsx": "react", 7 | "moduleResolution": "node", 8 | "noImplicitAny": false, 9 | "noUnusedLocals": false, 10 | "noUnusedParameters": false, 11 | "removeComments": true, 12 | "strictNullChecks": true, 13 | "preserveConstEnums": true, 14 | "sourceMap": true, 15 | "lib": ["es2015", "es2016", "dom"], 16 | "baseUrl": ".", 17 | "types": ["node"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /example/LayoutApp/Layout.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { RenderArea } from '../../.'; 3 | 4 | export function Layout() { 5 | return ( 6 |
14 |
15 |

Nav

16 | 17 |
18 |
19 |

Main

20 | 21 |
22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "parcel index.html", 8 | "build": "parcel build index.html" 9 | }, 10 | "dependencies": { 11 | "parcel-bundler": "^1.12.4", 12 | "react-router-dom": "^6.3.0" 13 | }, 14 | "alias": { 15 | "react": "../node_modules/react", 16 | "react-dom": "../node_modules/react-dom", 17 | "scheduler/tracing": "../node_modules/scheduler/tracing-profiling" 18 | }, 19 | "devDependencies": { 20 | "@types/react": "^16.9.11", 21 | "@types/react-dom": "^16.8.4", 22 | "typescript": "^3.4.5" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/RenderArea.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AreaContext, AreaContextValue } from './AreaContext'; 3 | 4 | const { useContext } = React; 5 | 6 | type RenderCallback = (components: React.ReactElement[]) => React.ReactNode; 7 | 8 | interface Props { 9 | name: string; 10 | children?: React.ReactNode | RenderCallback; 11 | } 12 | 13 | const EMPTY_ARRAY = [] as React.ReactElement[]; 14 | 15 | export const RenderArea: React.FunctionComponent = ({ 16 | name: areaId, 17 | children, 18 | }) => { 19 | const context = useContext(AreaContext); 20 | const components = context.getComponents(areaId); 21 | return ( 22 | typeof children === 'function' 23 | ? (children as RenderCallback)(components || EMPTY_ARRAY) 24 | : components 25 | ) as React.ReactElement; 26 | }; 27 | -------------------------------------------------------------------------------- /example/LayoutApp/Feature1.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useState } from 'react'; 3 | import { Content } from '../../src'; 4 | import { TestComponent } from '../TestComponent'; 5 | 6 | export const Feature1: React.FunctionComponent<{}> = () => { 7 | const [enabled, setEnabled] = useState(true); 8 | return ( 9 | <> 10 | 11 | 12 |
nav1 {enabled ? 'on' : 'off'}
13 |
14 |
15 | 16 | 17 | 27 | 28 | 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /example/LayoutApp/Feature2.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useState } from 'react'; 3 | import { Content, RenderArea } from '../../src'; 4 | import { TestComponent } from '../TestComponent'; 5 | 6 | export const Feature2: React.FunctionComponent<{}> = () => { 7 | const [enabled, setEnabled] = useState(true); 8 | return ( 9 | <> 10 | 11 | nav2 {enabled ? 'on' : 'off'} 12 | 13 | 14 | 15 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 everdimension 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. -------------------------------------------------------------------------------- /src/Content.tsx: -------------------------------------------------------------------------------- 1 | import React, { useLayoutEffect, useEffect, useContext, useRef } from 'react'; 2 | import { AreaContext } from './AreaContext'; 3 | 4 | interface ComponentData { 5 | value: React.ReactElement; 6 | orderNumber: number | null; 7 | } 8 | 9 | export function useRender(areaId: string, children: React.ReactElement) { 10 | const { addComponent, removeComponent, updateComponent, orderNumberRef } = 11 | useContext(AreaContext); 12 | const ref = useRef({ value: children, orderNumber: null }); 13 | 14 | if (ref.current.orderNumber == null) { 15 | ref.current.orderNumber = orderNumberRef.current; 16 | } 17 | orderNumberRef.current += 1; 18 | 19 | useLayoutEffect(() => { 20 | addComponent(areaId, ref.current); 21 | return () => { 22 | removeComponent(areaId, ref.current); 23 | }; 24 | }, [ref.current, addComponent, removeComponent]); 25 | 26 | useEffect(() => { 27 | if (children !== ref.current.value) { 28 | ref.current.value = children; 29 | updateComponent(areaId, ref.current); 30 | } 31 | }, [children]); 32 | } 33 | 34 | export const Content = ({ 35 | name: areaId, 36 | children, 37 | }: React.PropsWithChildren<{ name: string }>) => { 38 | useRender(areaId, children as React.ReactElement); 39 | return null; 40 | }; 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1.1-alpha.5", 3 | "license": "MIT", 4 | "type": "module", 5 | "source": "src/index.tsx", 6 | "exports": { 7 | "require": "./dist/index.cjs", 8 | "default": "./dist/index.modern.js" 9 | }, 10 | "types": "./dist/index.d.ts", 11 | "main": "./dist/index.cjs", 12 | "module": "./dist/index.module.js", 13 | "unpkg": "./dist/index.umd.js", 14 | "scripts": { 15 | "build": "microbundle --jsx React.createElement", 16 | "dev": "microbundle watch", 17 | "prepare": "npm run build" 18 | }, 19 | "peerDependencies": { 20 | "react": ">=16" 21 | }, 22 | "husky": { 23 | "hooks": { 24 | "pre-commit": "tsdx lint" 25 | } 26 | }, 27 | "prettier": { 28 | "printWidth": 80, 29 | "semi": true, 30 | "singleQuote": true, 31 | "trailingComma": "all" 32 | }, 33 | "name": "react-area", 34 | "author": "everdimension ", 35 | "devDependencies": { 36 | "@types/jest": "^24.9.1", 37 | "@types/react": "^16.9.19", 38 | "@types/react-dom": "^16.9.5", 39 | "husky": "^4.2.1", 40 | "microbundle": "^0.15.0", 41 | "react": "^18.0.0", 42 | "react-dom": "^18.0.0", 43 | "tslib": "^1.10.0", 44 | "typescript": "^4.4.4" 45 | }, 46 | "repository": { 47 | "type": "git", 48 | "url": "https://github.com/everdimension/react-area.git" 49 | }, 50 | "keywords": [ 51 | "ui area", 52 | "ui region", 53 | "extensibility", 54 | "extensible apps", 55 | "plugins", 56 | "pluggable ui", 57 | "slot fill", 58 | "react-area", 59 | "react" 60 | ] 61 | } 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Area 2 | 3 | _NOTE_: For now, this is an experimental Proof of Concept. 4 | 5 | ## Install 6 | 7 | ``` 8 | npm install react-area 9 | ``` 10 | 11 | ## Getting Started 12 | 13 | First, wrap your app in the `` component: 14 | 15 | ```js 16 | import { AreaProvider } from 'react-area'; 17 | import ReactDOM from 'react-dom'; 18 | import React from 'react'; 19 | 20 | ReactDOM.render( 21 | 22 | 23 | , 24 | document.getElementById('root'), 25 | ); 26 | ``` 27 | 28 | Next, anywhere in your app you can define an "render area" with a `` component: 29 | 30 | ```js 31 | import { RenderArea } from 'react-area'; 32 | import React from 'react'; 33 | 34 | function Layout() { 35 | return ( 36 |
37 | 40 |
41 | 42 |
43 |
44 | ); 45 | } 46 | ``` 47 | 48 | And then, from any other component you can render inside those areas 49 | by using the `` component: 50 | 51 | ```js 52 | import { Content } from 'react-area'; 53 | import React from 'react'; 54 | 55 | function SomeFeature() { 56 | return ( 57 | <> 58 | 59 | SomeFeature 60 | 61 | 62 | 63 |
Feature Content
64 |
65 | 66 | ); 67 | } 68 | ``` 69 | 70 | ## Prior Art 71 | 72 | This project is heavily inspired by these projects: 73 | 74 | - Slot Fill by Cam West: https://github.com/camwest/react-slot-fill 75 | - SlotFill by wordpress: https://github.com/WordPress/gutenberg/tree/master/packages/components/src/slot-fill 76 | -------------------------------------------------------------------------------- /src/AreaContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback, useMemo, useRef } from 'react'; 2 | 3 | interface ComponentHolder { 4 | value: React.ReactElement; 5 | orderNumber: number | null; 6 | } 7 | 8 | export interface AreaContextValue { 9 | addComponent: (areaId: string | number, component: ComponentHolder) => void; 10 | removeComponent: ( 11 | areaId: string | number, 12 | component: ComponentHolder, 13 | ) => void; 14 | updateComponent: ( 15 | areaId: string | number, 16 | component: ComponentHolder, 17 | ) => void; 18 | getComponents: (areaId: string) => React.ReactElement[] | null; 19 | orderNumberRef: { 20 | current: number; 21 | }; 22 | } 23 | 24 | export const AreaContext = React.createContext({ 25 | // @ts-ignore 26 | addComponent: (areaId: string, component: ComponentHolder) => {}, 27 | // @ts-ignore 28 | removeComponent: (areaId: string, component: ComponentHolder) => {}, 29 | // @ts-ignore 30 | updateComponent: (areaId: string, component: ComponentHolder) => {}, 31 | // @ts-ignore 32 | getComponents: (areaId: string) => null, 33 | orderNumberRef: { current: 0 }, 34 | }); 35 | 36 | export function AreaProvider(props: { children: React.ReactNode }) { 37 | const [components, setComponents] = useState<{ 38 | [key: string]: Set; 39 | }>({}); 40 | 41 | const addComponent = useCallback((areaId, component) => { 42 | setComponents((components) => { 43 | const existingComponents = components[areaId] || new Set(); 44 | if (existingComponents.has(component)) { 45 | console.log('component has already been registererd'); 46 | return components; // skip update 47 | } 48 | existingComponents.add(component); 49 | return { 50 | ...components, 51 | [areaId]: existingComponents, 52 | }; 53 | }); 54 | }, []); 55 | 56 | const removeComponent = useCallback((areaId, ref) => { 57 | setComponents((components) => { 58 | const existingComponents = components[areaId]; 59 | if (!existingComponents) { 60 | return components; // skip update 61 | } 62 | existingComponents.delete(ref); 63 | existingComponents.forEach((component) => { 64 | component.orderNumber = null; 65 | }); 66 | return { ...components }; 67 | }); 68 | }, []); 69 | 70 | const updateComponent = useCallback((areaId, _component) => { 71 | setComponents((components) => { 72 | const existingComponents = components[areaId]; 73 | if (!existingComponents) { 74 | return components; // skip update 75 | } 76 | return { ...components }; 77 | }); 78 | }, []); 79 | 80 | const getComponents = useCallback( 81 | (areaId) => { 82 | if (!(areaId in components)) { 83 | return null; 84 | } 85 | const areaComponents = Array.from(components[areaId]); 86 | 87 | // TODO: 88 | // Instead of sorting, we can "insert" the component at the correct 89 | // position by its ".orderNumber". Perhaps we need to use an alternative 90 | // to Set, though. 91 | areaComponents.sort((a, b) => a.orderNumber! - b.orderNumber!); 92 | return areaComponents.map((component) => component.value); 93 | }, 94 | [components], 95 | ); 96 | 97 | const orderNumberRef = useRef(0); 98 | 99 | const value = useMemo( 100 | () => ({ 101 | addComponent, 102 | getComponents, 103 | removeComponent, 104 | updateComponent, 105 | orderNumberRef, 106 | }), 107 | [addComponent, getComponents, removeComponent, updateComponent], 108 | ); 109 | 110 | return ; 111 | } 112 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useState } from 'react'; 3 | import { createRoot } from 'react-dom/client'; 4 | import { AreaProvider, RenderArea, Content } from '../src/index'; 5 | import { Layout } from './LayoutApp/Layout'; 6 | import { Feature1 } from './LayoutApp/Feature1'; 7 | import { Feature2 } from './LayoutApp/Feature2'; 8 | import { TestComponent } from './TestComponent'; 9 | import { 10 | BrowserRouter, 11 | Link, 12 | Route, 13 | Routes, 14 | useLocation, 15 | } from 'react-router-dom'; 16 | 17 | function Main() { 18 | const [showArea, setShowArea] = useState(true); 19 | const [show1, setShow1] = useState(true); 20 | const [show2, setShow2] = useState(false); 21 | 22 | return ( 23 |
24 |

App testing

25 |
26 |

27 | First area{' '} 28 | 29 |

30 | {showArea ? : null} 31 |
32 | 33 |

hello

34 |
35 | 36 |

world

37 |
38 |
39 | 40 | {(components) => ( 41 | <> 42 |

Second area: {components.length} elements rendered

43 | 44 | {components} 45 | 46 | )} 47 |
48 | 49 |
50 | 51 |
52 | 53 | just text 54 |

world

55 |
56 | {show1 ? ( 57 | 58 |
This should be first
59 |
60 | ) : null} 61 | 62 | {show2 ?
This should be second
: null} 63 |
64 | 65 |
This should be last
66 |
67 |
68 | 69 |
70 | 71 |
72 | 73 |
74 | 75 | 76 | test1 test3; prev visible: {String(show2)} 77 | 78 | 79 | 80 | test2 81 | 82 | 83 | 84 | test3; prev visible: {String(show2)} 85 | 86 | 87 |
88 |
89 | 90 | 91 | 92 |
93 |
94 | ); 95 | } 96 | 97 | function TestRenderCallback() { 98 | const { pathname } = useLocation(); 99 | console.log('rendering TestRenderCallback'); 100 | return ( 101 |
102 | { 105 | console.log(pathname, components.length); 106 | return
We have {components.length} components
; 107 | }} 108 | >
109 | 110 | 114 | Start Go to 2 115 |
116 | } 117 | > 118 | 122 | Finish 123 | content 124 | 125 | } 126 | > 127 | 128 | 129 | ); 130 | } 131 | 132 | const App = () => { 133 | return ( 134 | 135 | 136 | Main Test2... 137 | 138 | } /> 139 | } /> 140 | 141 | 142 | 143 | ); 144 | }; 145 | 146 | createRoot(document.getElementById('root')).render(); 147 | --------------------------------------------------------------------------------