├── .gitignore ├── .yarnrc ├── LICENSE ├── README.md ├── example ├── .gitignore ├── index.html ├── index.tsx ├── package.json ├── tsconfig.json └── yarn.lock ├── package.json ├── src ├── index.ts └── utils.ts ├── test └── index.test.tsx ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | dist 6 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Kelson Warner 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-chrome-extension-router 2 | 3 | > A dead simple routing solution for browser extensions 4 | 5 | [![NPM](https://img.shields.io/npm/v/react-chrome-extension-router.svg)](https://www.npmjs.com/package/react-chrome-extension-router) [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) 6 | 7 | ## Install 8 | 9 | ```bash 10 | npm install --save react-chrome-extension-router 11 | ``` 12 | 13 | ## Usage 14 | 15 | ```jsx 16 | import * as React from 'react'; 17 | import * as ReactDOM from 'react-dom'; 18 | import { 19 | goBack, 20 | goTo, 21 | popToTop, 22 | Link, 23 | Router, 24 | getCurrent, 25 | getComponentStack, 26 | } from 'react-chrome-extension-router'; 27 | 28 | const Three = ({ message }: any) => ( 29 |
popToTop()}> 30 |

{message}

31 |

Click me to pop to the top

32 |
33 | ); 34 | 35 | const Two = ({ message }: any) => ( 36 |
37 | This is component Two. I was passed a message: 38 |

{message}

39 | 42 | 45 |
46 | ); 47 | 48 | const One = () => { 49 | return ( 50 | 51 | This is component One. Click me to route to component Two 52 | 53 | ); 54 | }; 55 | 56 | const App = () => { 57 | useEffect(() => { 58 | const { component, props } = getCurrent(); 59 | console.log( 60 | component 61 | ? `There is a component on the stack! ${component} with ${props}` 62 | : `The current stack is empty so Router's direct children will be rendered` 63 | ); 64 | const components = getComponentStack(); 65 | console.log(`The stack has ${components.length} components on the stack`); 66 | }); 67 | return ( 68 | 69 | 70 | 71 | ); 72 | }; 73 | 74 | ReactDOM.render(, document.getElementById('root')); 75 | ``` 76 | 77 | [![Edit agitated-satoshi-sccqr](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/agitated-satoshi-sccqr?fontsize=14) 78 | 79 | ## License 80 | 81 | MIT © [kelsonpw](https://github.com/kelsonpw) 82 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Playground 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import 'react-app-polyfill/ie11'; 2 | import * as React from 'react'; 3 | import * as ReactDOM from 'react-dom'; 4 | import { 5 | goBack, 6 | goTo, 7 | popToTop, 8 | Link, 9 | Router, 10 | getCurrent, 11 | getComponentStack, 12 | } from 'react-chrome-extension-router'; 13 | 14 | const Three = ({ message }: any) => ( 15 |
popToTop()}> 16 |

{message}

17 |

Click me to pop to the top

18 |
19 | ); 20 | 21 | const Two = ({ message }: any) => ( 22 |
23 | This is component Two. I was passed a message: 24 |

{message}

25 | 28 | 31 |
32 | ); 33 | 34 | const One = () => { 35 | return ( 36 | 37 | This is component One. Click me to route to component Two 38 | 39 | ); 40 | }; 41 | 42 | const App = () => { 43 | const logComponentStack = React.useCallback(() => { 44 | const { component, props } = getCurrent(); 45 | console.log( 46 | component 47 | ? `There is a component on the stack! ${component} with ${props}` 48 | : `The current stack is empty so Router's direct children will be rendered` 49 | ); 50 | const components = getComponentStack(); 51 | console.log(`The stack has ${components.length} components on the stack`); 52 | }, []); 53 | 54 | React.useEffect(() => { 55 | const timeoutId = setTimeout(logComponentStack, 5000); 56 | 57 | return () => clearTimeout(timeoutId); 58 | }, [logComponentStack]); 59 | 60 | return ( 61 | 62 | 63 | 64 | ); 65 | }; 66 | 67 | ReactDOM.render(, document.getElementById('root')); 68 | -------------------------------------------------------------------------------- /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 | "react-app-polyfill": "^1.0.0", 12 | "react-chrome-extension-router": "^1.1.0" 13 | }, 14 | "alias": { 15 | "react": "../node_modules/react", 16 | "react-dom": "../node_modules/react-dom/profiling", 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 | "parcel": "^1.12.3", 23 | "typescript": "^3.4.5" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.4.0", 3 | "license": "MIT", 4 | "main": "dist/index.js", 5 | "typings": "dist/index.d.ts", 6 | "files": [ 7 | "dist" 8 | ], 9 | "scripts": { 10 | "start": "tsdx watch", 11 | "build": "tsdx build", 12 | "test": "tsdx test --passWithNoTests", 13 | "lint": "tsdx lint", 14 | "prepare": "tsdx build" 15 | }, 16 | "peerDependencies": { 17 | "react": ">=16" 18 | }, 19 | "husky": { 20 | "hooks": { 21 | "pre-commit": "tsdx lint" 22 | } 23 | }, 24 | "prettier": { 25 | "printWidth": 80, 26 | "semi": true, 27 | "singleQuote": true, 28 | "trailingComma": "es5" 29 | }, 30 | "name": "react-chrome-extension-router", 31 | "description": "A dead simple routing solution for browser extensions using React", 32 | "keywords": [ 33 | "react", 34 | "react-router", 35 | "router", 36 | "react router", 37 | "chrome", 38 | "browser", 39 | "extension", 40 | "chrome-extension", 41 | "micro router", 42 | "micro react router" 43 | ], 44 | "author": "Kelson Warner", 45 | "repository": { 46 | "type": "git", 47 | "url": "https://github.com/kelsonpw/react-chrome-extension-router.git" 48 | }, 49 | "module": "dist/react-chrome-extension-router.esm.js", 50 | "devDependencies": { 51 | "@testing-library/dom": "^7.28.1", 52 | "@testing-library/react": "^9.4.0", 53 | "@types/jest": "^24.0.25", 54 | "@types/react": "^16.9.17", 55 | "@types/react-dom": "^16.9.4", 56 | "husky": "^4.0.7", 57 | "react": "^16.12.0", 58 | "react-dom": "^16.12.0", 59 | "tsdx": "^0.12.3", 60 | "tslib": "^1.10.0", 61 | "typescript": "^3.7.4" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useForceUpdate } from './utils'; 3 | 4 | type RouterStackItem = { 5 | component: React.ComponentType; 6 | props: any; 7 | }; 8 | 9 | let stack: RouterStackItem[] = []; 10 | 11 | let forceUpdateStack: (() => void)[] = []; 12 | 13 | function useForceUpdateStack(): void { 14 | const update = useForceUpdate(); 15 | 16 | React.useEffect(() => { 17 | forceUpdateStack.push(update); 18 | 19 | return () => { 20 | const index = forceUpdateStack.indexOf(update); 21 | if (index > -1) { 22 | forceUpdateStack.splice(index, 1); 23 | } 24 | }; 25 | }, [update]); 26 | } 27 | 28 | function forceUpdate() { 29 | forceUpdateStack.forEach(fn => fn()); 30 | } 31 | 32 | function goTo(comp: React.ComponentType, props: any = {}): void { 33 | stack.push({ component: comp, props }); 34 | forceUpdate(); 35 | } 36 | 37 | function goBack(): void { 38 | if (stack.length) { 39 | stack.pop(); 40 | } 41 | forceUpdate(); 42 | } 43 | 44 | function popToTop(): void { 45 | stack = []; 46 | forceUpdate(); 47 | } 48 | 49 | function getCurrent(): RouterStackItem { 50 | return stack[stack.length - 1] || { component: false, props: null }; 51 | } 52 | 53 | function getComponentStack(): RouterStackItem[] { 54 | return stack; 55 | } 56 | 57 | interface LinkProps { 58 | id?: string; 59 | component: React.ComponentType; 60 | children?: React.ReactNode; 61 | props?: any; 62 | href?: string; 63 | className?: string; 64 | tag?: React.ComponentType | keyof JSX.IntrinsicElements; 65 | onClick?: (event: React.MouseEvent) => void; 66 | } 67 | 68 | function Link({ 69 | id = '', 70 | component, 71 | children, 72 | props = {}, 73 | href = '', 74 | className = '', 75 | tag = 'a', 76 | onClick, 77 | ...restProps 78 | }: LinkProps & React.HTMLProps) { 79 | const onClickHandler = React.useCallback( 80 | (evt: React.MouseEvent) => { 81 | evt.preventDefault(); 82 | if (component) { 83 | goTo(component, props); 84 | } 85 | if (!component && href) { 86 | window.open(href); 87 | } 88 | onClick && onClick(evt); 89 | }, 90 | [component, props, href, onClick] 91 | ); 92 | 93 | return React.createElement( 94 | tag, 95 | { 96 | href, 97 | className, 98 | id, 99 | onClick: onClickHandler, 100 | ...restProps, 101 | }, 102 | children 103 | ); 104 | } 105 | 106 | interface NavLinkProps { 107 | id?: string; 108 | component: React.ComponentType; 109 | children?: React.ReactNode; 110 | props?: any; 111 | href?: string; 112 | className?: string; 113 | activeClassName?: string; 114 | tag?: React.ComponentType | keyof JSX.IntrinsicElements; 115 | onClick?: (event: React.MouseEvent) => void; 116 | } 117 | 118 | function NavLink({ 119 | id = '', 120 | component, 121 | children, 122 | props = {}, 123 | href = '', 124 | className = '', 125 | activeClassName = '', 126 | tag = 'a', 127 | onClick, 128 | ...restProps 129 | }: NavLinkProps & React.HTMLProps) { 130 | const onClickHandler = React.useCallback( 131 | (evt: React.MouseEvent) => { 132 | evt.preventDefault(); 133 | if (component) { 134 | goTo(component, props); 135 | } 136 | if (!component && href) { 137 | window.open(href); 138 | } 139 | onClick && onClick(evt); 140 | }, 141 | [component, props, href, onClick] 142 | ); 143 | 144 | useForceUpdateStack(); 145 | 146 | if (stack.length > 0 && stack[stack.length - 1].component === component) { 147 | className = activeClassName + ' ' + className; 148 | } 149 | 150 | return React.createElement( 151 | tag, 152 | { 153 | href, 154 | className, 155 | id, 156 | onClick: onClickHandler, 157 | ...restProps, 158 | }, 159 | children 160 | ); 161 | } 162 | 163 | interface RouterProps { 164 | children: React.ReactNode; 165 | } 166 | 167 | const emptyStackComponent: RouterStackItem = { 168 | component: ({ children }: any) => children, 169 | props: {}, 170 | }; 171 | 172 | function Router({ children }: RouterProps) { 173 | useForceUpdateStack(); 174 | 175 | const { component: Component, props } = 176 | stack[stack.length - 1] || emptyStackComponent; 177 | 178 | return React.createElement(Component, props, children); 179 | } 180 | 181 | export { 182 | goBack, 183 | getCurrent, 184 | getComponentStack, 185 | goTo, 186 | popToTop, 187 | LinkProps, 188 | RouterProps, 189 | Link, 190 | NavLink, 191 | Router, 192 | }; 193 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react'; 2 | 3 | function useForceUpdate(): () => void { 4 | const [, dispatch] = useState(Object.create(null)); 5 | 6 | return useCallback(() => { 7 | dispatch(Object.create(null)); 8 | }, [dispatch]); 9 | } 10 | 11 | export { useForceUpdate }; 12 | -------------------------------------------------------------------------------- /test/index.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render, fireEvent } from '@testing-library/react'; 3 | import { goBack, goTo, popToTop, Link, Router } from '../src/index'; 4 | 5 | const Three = ({ message }: any) => ( 6 |
popToTop()}> 7 |

{message}

8 |

Click me to pop to the top

9 |
10 | ); 11 | 12 | const Two = ({ message }: any) => ( 13 |
14 | This is component Two. I was passed a message: 15 |

{message}

16 | 19 | 22 |
23 | ); 24 | 25 | const One = () => { 26 | return ( 27 | 28 | This is component One. Click me to route to component Two 29 | 30 | ); 31 | }; 32 | 33 | const App = () => ( 34 | 35 | 36 | 37 | ); 38 | 39 | describe('react-chrome-extension-router', () => { 40 | it('allows navigation between pages', () => { 41 | const { getByText } = render(); 42 | 43 | expect( 44 | getByText('This is component One. Click me to route to component Two') 45 | ).not.toBeNull(); 46 | 47 | fireEvent.click( 48 | getByText('This is component One. Click me to route to component Two') 49 | ); 50 | 51 | expect(getByText('I came from component one!')).not.toBeNull(); 52 | 53 | fireEvent.click(getByText('Click me to go back to component One')); 54 | 55 | expect( 56 | getByText('This is component One. Click me to route to component Two') 57 | ).not.toBeNull(); 58 | 59 | fireEvent.click( 60 | getByText('This is component One. Click me to route to component Two') 61 | ); 62 | 63 | fireEvent.click(getByText('Click me to go to component Three!')); 64 | 65 | expect(getByText('I came from component one!')).not.toBeNull(); 66 | 67 | fireEvent.click(getByText('Click me to pop to the top')); 68 | 69 | expect( 70 | getByText('This is component One. Click me to route to component Two') 71 | ).not.toBeNull(); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types", "test"], 3 | "compilerOptions": { 4 | "target": "es5", 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./", 11 | "strict": true, 12 | "noImplicitAny": true, 13 | "strictNullChecks": true, 14 | "strictFunctionTypes": true, 15 | "strictPropertyInitialization": true, 16 | "noImplicitThis": true, 17 | "alwaysStrict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noImplicitReturns": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "moduleResolution": "node", 23 | "baseUrl": "./", 24 | "paths": { 25 | "*": ["src/*", "node_modules/*"] 26 | }, 27 | "jsx": "react", 28 | "esModuleInterop": true 29 | } 30 | } 31 | --------------------------------------------------------------------------------