├── .gitignore ├── .prettierrc.json ├── README.md ├── jest.unit.config.json ├── package.json ├── src ├── resources │ ├── background.html │ ├── browserAction.html │ ├── images │ │ ├── logo-128x128.png │ │ ├── logo-16x16.png │ │ └── logo-48x48.png │ ├── manifest.json │ └── settings.html └── scripts │ ├── background │ └── index.tsx │ ├── browserAction │ └── index.tsx │ ├── common │ ├── ChildPageApp.tsx │ ├── messaging.ts │ └── state.ts │ ├── contentScript │ └── index.tsx │ ├── lib │ ├── hooks │ │ ├── useDisposables.ts │ │ ├── useInterval.ts │ │ ├── usePorts.test.tsx │ │ ├── usePorts.ts │ │ ├── useSyncedStateFromBackground.test.tsx │ │ ├── useSyncedStateFromBackground.ts │ │ ├── useSyncedStateFromChildPage.test.tsx │ │ └── useSyncedStateFromChildPage.ts │ └── messaging.ts │ ├── settings │ └── index.tsx │ └── test │ └── utils.ts ├── tsconfig-webpack.json ├── tsconfig.json ├── webpack.config.ts └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | yarn-error.log -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100 3 | } 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Hooks Chrome Extension 2 | 3 | The following is more of a proof of concept showing how you can go about using React Hooks to create a chrome extension with synced state between the various pages. 4 | 5 | It also demonstrates how we can go about unit testing React Hooks. 6 | 7 | Video of it in action here: https://www.youtube.com/watch?v=FHjZnk6JYVQ 8 | 9 | ## To Run 10 | 11 | `yarn install` 12 | `yarn dev` 13 | 14 | Then load unpacked extension in chrome 15 | 16 | ## To Test 17 | 18 | `yarn test` 19 | -------------------------------------------------------------------------------- /jest.unit.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "testURL": "http://localhost", 3 | "transform": { 4 | "^.+\\.tsx?$": "ts-jest" 5 | }, 6 | "testMatch": ["**/?(*.)(spec|test).[tj]s?(x)"], 7 | "testPathIgnorePatterns": ["integration.test", "node_modules"], 8 | "moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"] 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-hooks-chrome-extension", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "repository": "git@github.com:mikecann/react-hooks-chrome-extension.git", 6 | "author": "Mike Cann ", 7 | "license": "MIT", 8 | "scripts": { 9 | "test": "jest -c jest.unit.config.json", 10 | "build": "yarn build:resources && yarn build:scripts", 11 | "build:resources": "cpx ./src/resources/**/*.* ./dist", 12 | "build:scripts": "cross-env TS_NODE_PROJECT=\"tsconfig-webpack.json\" webpack", 13 | "watch": "concurrently -n scripts,resources -c cyan,yellow,magenta \"yarn watch:scripts\" \"yarn watch:resources\" \"yarn watch:tests\" ", 14 | "watch:scripts": "yarn build:scripts --watch", 15 | "watch:resources": "yarn build:resources && onchange src/resources/**/*.* -- yarn build:resources", 16 | "watch:tests": "yarn test --watch", 17 | "dev": "yarn watch" 18 | }, 19 | "devDependencies": { 20 | "@types/chrome": "^0.0.75", 21 | "@types/jest": "^23.3.10", 22 | "@types/node": "^10.12.15", 23 | "@types/react": "^16.7.17", 24 | "@types/react-dom": "^16.0.11", 25 | "awesome-typescript-loader": "^5.2.1", 26 | "concurrently": "^4.1.0", 27 | "cpx": "^1.5.0", 28 | "cross-env": "^5.2.0", 29 | "husky": "^1.2.1", 30 | "jest": "^23.6.0", 31 | "onchange": "^5.2.0", 32 | "prettier": "^1.15.3", 33 | "pretty-quick": "^1.8.0", 34 | "react-test-renderer": "^16.7.0-alpha.2", 35 | "react-testing-library": "^5.4.2", 36 | "source-map-loader": "^0.2.4", 37 | "ts-jest": "^23.10.5", 38 | "ts-node": "^7.0.1", 39 | "typescript": "^3.2.2", 40 | "webpack": "^4.27.1", 41 | "webpack-cli": "^3.1.2" 42 | }, 43 | "dependencies": { 44 | "react": "^16.7.0-alpha.2", 45 | "react-dom": "^16.7.0-alpha.2" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/resources/background.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | React Hooks Chrome Extension 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/resources/browserAction.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | React Hooks Example Chrome Extension - Browser Action 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/resources/images/logo-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikecann/react-hooks-chrome-extension/a44f27947770e00839879401c0e76cae5bdcdd33/src/resources/images/logo-128x128.png -------------------------------------------------------------------------------- /src/resources/images/logo-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikecann/react-hooks-chrome-extension/a44f27947770e00839879401c0e76cae5bdcdd33/src/resources/images/logo-16x16.png -------------------------------------------------------------------------------- /src/resources/images/logo-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikecann/react-hooks-chrome-extension/a44f27947770e00839879401c0e76cae5bdcdd33/src/resources/images/logo-48x48.png -------------------------------------------------------------------------------- /src/resources/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "React Hooks Chrome Extension", 4 | "description": "Demoing react hooks functionality in chrome extensions", 5 | "version": "1.0.0", 6 | "browser_action": { 7 | "default_icon": "images/logo-128x128.png", 8 | "default_popup": "browserAction.html" 9 | }, 10 | "content_scripts": [ 11 | { 12 | "matches": ["http://*/*", "https://*/*"], 13 | "js": ["contentScript-bundle.js"] 14 | } 15 | ], 16 | "icons": { 17 | "16": "images/logo-16x16.png", 18 | "48": "images/logo-48x48.png", 19 | "128": "images/logo-128x128.png" 20 | }, 21 | "options_page": "settings.html", 22 | "background": { 23 | "page": "background.html" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/resources/settings.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | React Hooks Example Chrome Extension - Settings 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/scripts/background/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ReactDOM from "react-dom"; 3 | import { useSyncStateFromBg } from "../common/state"; 4 | import { useInterval } from "../lib/hooks/useInterval"; 5 | 6 | function App() { 7 | const [state, updateState] = useSyncStateFromBg(); 8 | 9 | useInterval({ 10 | intervalMs: 1000, 11 | callback: () => updateState(prev => ({ ...prev, count: prev.count + 1 })) 12 | }); 13 | 14 | console.log("APP RENDER", state); 15 | return <>; 16 | } 17 | 18 | console.log("hello from the background page"); 19 | 20 | ReactDOM.render(, document.getElementById("root")); 21 | -------------------------------------------------------------------------------- /src/scripts/browserAction/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ReactDOM from "react-dom"; 3 | import { ChildPageApp } from "../common/ChildPageApp"; 4 | 5 | console.log("hello from the browser action page.."); 6 | 7 | ReactDOM.render(, document.getElementById("root")); 8 | -------------------------------------------------------------------------------- /src/scripts/common/ChildPageApp.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useState } from "react"; 3 | import { useSyncStateFromChild } from "./state"; 4 | 5 | function MiniChildPage(props: { index: number; onRemove: () => void; name: string }) { 6 | const [state, update] = useSyncStateFromChild(props.name); 7 | 8 | return ( 9 |
10 |

11 | [{props.index}] count: {state.count} 12 |

13 | 20 | 21 |
22 | ); 23 | } 24 | 25 | let childId = 0; 26 | export function ChildPageApp(props: { name: string }) { 27 | const [children, setChildren] = useState([]); 28 | const removeChild = (indx: number) => setChildren(children.filter(c => c != indx)); 29 | const addChild = () => setChildren([...children, childId++]); 30 | return ( 31 |
32 | {children.map(c => ( 33 | removeChild(c)} /> 34 | ))} 35 | 36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /src/scripts/common/messaging.ts: -------------------------------------------------------------------------------- 1 | import { MessagesFromPayloads, messagingServiceFactory } from "../lib/messaging"; 2 | import { ExtensionState } from "./state"; 3 | 4 | type MessagePayloads = { 5 | "some-other-message": undefined; 6 | "some-other-message2": { prop: string }; 7 | }; 8 | 9 | type Messages = MessagesFromPayloads; 10 | 11 | export type SomeOtherMessage = Messages["some-other-message"]; 12 | export type SomeOtherMessage2 = Messages["some-other-message"]; 13 | 14 | export type ExtensionMessages = Messages[keyof Messages]; 15 | 16 | export const messaging = messagingServiceFactory(); 17 | -------------------------------------------------------------------------------- /src/scripts/common/state.ts: -------------------------------------------------------------------------------- 1 | import { useSyncedStateFromBackground } from "../lib/hooks/useSyncedStateFromBackground"; 2 | import { useSyncedStateFromChildPage } from "../lib/hooks/useSyncedStateFromChildPage"; 3 | 4 | export type ExtensionState = { 5 | count: number; 6 | msg: string; 7 | }; 8 | 9 | const initialState: ExtensionState = { count: 0, msg: "foo" }; 10 | 11 | export const useSyncStateFromBg = () => useSyncedStateFromBackground(initialState); 12 | 13 | export const useSyncStateFromChild = (name: string) => 14 | useSyncedStateFromChildPage(name, initialState); 15 | -------------------------------------------------------------------------------- /src/scripts/contentScript/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ReactDOM from "react-dom"; 3 | import { ChildPageApp } from "../common/ChildPageApp"; 4 | 5 | console.log("hello from the content script page.."); 6 | 7 | const div = document.createElement("div"); 8 | document.body.appendChild(div); 9 | ReactDOM.render(, div); 10 | -------------------------------------------------------------------------------- /src/scripts/lib/hooks/useDisposables.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from "react"; 2 | 3 | export function useDisposables() { 4 | const disposables = useRef([]); 5 | useEffect(() => () => disposables.current.forEach(d => d()), []); 6 | return (d: Function) => disposables.current.push(d); 7 | } 8 | -------------------------------------------------------------------------------- /src/scripts/lib/hooks/useInterval.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export function useInterval(options: { intervalMs: number; callback?: () => void }) { 4 | useEffect(() => { 5 | const interval = setInterval(() => { 6 | options.callback && options.callback(); 7 | }, options.intervalMs); 8 | return () => clearInterval(interval); 9 | }, []); 10 | } 11 | -------------------------------------------------------------------------------- /src/scripts/lib/hooks/usePorts.test.tsx: -------------------------------------------------------------------------------- 1 | const chrome = { 2 | runtime: { 3 | onConnect: { 4 | addListener: jest.fn(), 5 | removeListener: jest.fn() 6 | } 7 | } 8 | }; 9 | 10 | global["chrome"] = chrome; 11 | 12 | import * as React from "react"; 13 | import { render } from "react-testing-library"; 14 | import { usePorts, resetPortId } from "./usePorts"; 15 | import { portFactory } from "../../test/utils"; 16 | 17 | let state: ReturnType | undefined; 18 | 19 | function SomeComponent() { 20 | state = usePorts(jest.fn()); 21 | return
hello
; 22 | } 23 | 24 | beforeEach(() => { 25 | state = undefined; 26 | jest.clearAllMocks(); 27 | resetPortId(); 28 | }); 29 | 30 | it("should initialize to the expected values", () => { 31 | const { rerender } = render(); 32 | rerender(); 33 | expect(state).toEqual({ ports: [], lastConnected: undefined, lastDisconnected: undefined }); 34 | }); 35 | 36 | it("should add a port when one connects", () => { 37 | const { rerender } = render(); 38 | rerender(); 39 | const port = portFactory(); 40 | chrome.runtime.onConnect.addListener.mock.calls[0][0](port); 41 | expect(state).toEqual({ ports: [port], lastConnected: port, lastDisconnected: undefined }); 42 | expect(state!.ports[0].id).toEqual(0); 43 | }); 44 | 45 | it("should add another port when it connects", () => { 46 | const { rerender } = render(); 47 | rerender(); 48 | const portA = portFactory(); 49 | const portB = portFactory(); 50 | chrome.runtime.onConnect.addListener.mock.calls[0][0](portA); 51 | chrome.runtime.onConnect.addListener.mock.calls[0][0](portB); 52 | expect(state).toEqual({ 53 | ports: [portA, portB], 54 | lastConnected: portB, 55 | lastDisconnected: undefined 56 | }); 57 | expect(state!.ports[0].id).toEqual(0); 58 | expect(state!.ports[1].id).toEqual(1); 59 | }); 60 | 61 | it("should remove a port when it disconnects", () => { 62 | const { rerender } = render(); 63 | rerender(); 64 | const portA = portFactory(); 65 | const portB = portFactory(); 66 | chrome.runtime.onConnect.addListener.mock.calls[0][0](portA); 67 | chrome.runtime.onConnect.addListener.mock.calls[0][0](portB); 68 | portA.onDisconnect.addListener.mock.calls[0][0](portA); 69 | expect(state).toEqual({ 70 | ports: [portB], 71 | lastConnected: portB, 72 | lastDisconnected: portA 73 | }); 74 | expect(state!.ports[0].id).toEqual(1); 75 | }); 76 | 77 | it("should stop listening to runtime when unmounted", () => { 78 | const { unmount, rerender } = render(); 79 | rerender(); 80 | unmount(); 81 | expect(chrome.runtime.onConnect.removeListener.mock.calls.length).toBe(1); 82 | }); 83 | 84 | it("should stop listening to port disconnects when unmounted", () => { 85 | const { unmount, rerender } = render(); 86 | rerender(); 87 | const port = portFactory(); 88 | chrome.runtime.onConnect.addListener.mock.calls[0][0](port); 89 | expect(state).toEqual({ ports: [port], lastConnected: port, lastDisconnected: undefined }); 90 | expect(state!.ports[0].id).toEqual(0); 91 | expect(port.onDisconnect.removeListener.mock.calls.length).toBe(0); 92 | unmount(); 93 | expect(port.onDisconnect.removeListener.mock.calls.length).toBe(1); 94 | expect(port.onDisconnect.removeListener.mock.calls[0][0]).toBe( 95 | port.onDisconnect.addListener.mock.calls[0][0] 96 | ); 97 | }); 98 | -------------------------------------------------------------------------------- /src/scripts/lib/hooks/usePorts.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from "react"; 2 | import { useDisposables } from "./useDisposables"; 3 | 4 | export type Port = chrome.runtime.Port & { 5 | id: number; 6 | toString: () => string; 7 | isDisconnected: boolean | undefined; 8 | }; 9 | 10 | let portId = 0; 11 | 12 | export const resetPortId = () => (portId = 0); 13 | 14 | type State = { 15 | ports: Port[]; 16 | lastConnected?: Port | undefined; 17 | lastDisconnected?: Port | undefined; 18 | }; 19 | 20 | export function usePorts(logger = console.log) { 21 | const [state, updateState] = useState({ 22 | ports: [] 23 | }); 24 | 25 | const dispose = useDisposables(); 26 | 27 | function onPortConnected(port: Port) { 28 | port.id = portId++; 29 | port.toString = () => `[${port.id}] ${port.name}`; 30 | 31 | logger(`Port '${port}' connected`); 32 | 33 | function onPortDisconnect(port: Port) { 34 | logger(`Port '${port}' disconnected`); 35 | port.isDisconnected = true; 36 | 37 | updateState(prev => ({ 38 | ...prev, 39 | ports: prev.ports.filter(p => p != port), 40 | lastDisconnected: port 41 | })); 42 | } 43 | 44 | port.onDisconnect.addListener(onPortDisconnect); 45 | dispose(() => port.onDisconnect.removeListener(onPortDisconnect)); 46 | 47 | updateState(prev => ({ ...prev, ports: [...prev.ports, port], lastConnected: port })); 48 | } 49 | 50 | useEffect(() => { 51 | chrome.runtime.onConnect.addListener(onPortConnected); 52 | return () => { 53 | chrome.runtime.onConnect.removeListener(onPortConnected); 54 | }; 55 | }, []); 56 | 57 | return state; 58 | } 59 | -------------------------------------------------------------------------------- /src/scripts/lib/hooks/useSyncedStateFromBackground.test.tsx: -------------------------------------------------------------------------------- 1 | const chrome = { 2 | runtime: { 3 | onConnect: { 4 | addListener: jest.fn(), 5 | removeListener: jest.fn() 6 | } 7 | } 8 | }; 9 | 10 | global["chrome"] = chrome; 11 | 12 | import * as React from "react"; 13 | import { render } from "react-testing-library"; 14 | import { resetPortId } from "./usePorts"; 15 | import { useSyncedStateFromBackground } from "./useSyncedStateFromBackground"; 16 | import { portFactory } from "../../test/utils"; 17 | 18 | let hook: ReturnType | undefined; 19 | const initialState = { a: "foo" }; 20 | 21 | function SomeComponent() { 22 | hook = useSyncedStateFromBackground(initialState, jest.fn()); 23 | return
hello
; 24 | } 25 | 26 | beforeEach(() => { 27 | hook = undefined; 28 | jest.clearAllMocks(); 29 | resetPortId(); 30 | }); 31 | 32 | it("should initialize to the expected values", () => { 33 | const { rerender } = render(); 34 | rerender(); 35 | expect(hook![0]).toEqual({ a: "foo" }); 36 | }); 37 | 38 | it("should be able to have its state updated", () => { 39 | const { rerender } = render(); 40 | rerender(); 41 | hook![1]({ a: "bar" }); 42 | expect(hook![0]).toEqual({ a: "bar" }); 43 | }); 44 | 45 | it("should update its state when a port sends a state update message", () => { 46 | const { rerender } = render(); 47 | rerender(); 48 | const port = portFactory(); 49 | chrome.runtime.onConnect.addListener.mock.calls[0][0](port); 50 | rerender(); 51 | port.onMessage.addListener.mock.calls[0][0]({ type: "state-update", payload: { a: "derp" } }); 52 | rerender(); 53 | expect(hook![0]).toEqual({ a: "derp" }); 54 | }); 55 | 56 | it("should send the initial state when a port connects", () => { 57 | const { rerender } = render(); 58 | rerender(); 59 | const port = portFactory(); 60 | chrome.runtime.onConnect.addListener.mock.calls[0][0](port); 61 | rerender(); 62 | expect(port.postMessage.mock.calls).toEqual([[{ type: "state-update", payload: { a: "foo" } }]]); 63 | }); 64 | 65 | it("should update the other ports when the state is changed locally", () => { 66 | const { rerender } = render(); 67 | rerender(); 68 | const portA = portFactory(); 69 | const portB = portFactory(); 70 | chrome.runtime.onConnect.addListener.mock.calls[0][0](portA); 71 | chrome.runtime.onConnect.addListener.mock.calls[0][0](portB); 72 | hook![1]({ a: "bar" }); 73 | rerender(); 74 | expect(portA.postMessage.mock.calls).toEqual([ 75 | [{ type: "state-update", payload: { a: "foo" } }], 76 | [{ type: "state-update", payload: { a: "bar" } }] 77 | ]); 78 | expect(portB.postMessage.mock.calls).toEqual([ 79 | [{ type: "state-update", payload: { a: "foo" } }], 80 | [{ type: "state-update", payload: { a: "bar" } }] 81 | ]); 82 | }); 83 | 84 | it("should prevent circular updates", () => { 85 | const { rerender } = render(); 86 | rerender(); 87 | const portA = portFactory(); 88 | const portB = portFactory(); 89 | chrome.runtime.onConnect.addListener.mock.calls[0][0](portA); 90 | chrome.runtime.onConnect.addListener.mock.calls[0][0](portB); 91 | rerender(); 92 | portA.onMessage.addListener.mock.calls[0][0]({ type: "state-update", payload: { a: "derp" } }); 93 | rerender(); 94 | expect(portA.postMessage.mock.calls).toEqual([[{ type: "state-update", payload: { a: "foo" } }]]); 95 | expect(portB.postMessage.mock.calls).toEqual([ 96 | [{ type: "state-update", payload: { a: "foo" } }], 97 | [{ type: "state-update", payload: { a: "derp" } }] 98 | ]); 99 | }); 100 | 101 | it("should stop listening to all ports when unmounted", () => { 102 | const { unmount, rerender } = render(); 103 | rerender(); 104 | const portA = portFactory(); 105 | const portB = portFactory(); 106 | chrome.runtime.onConnect.addListener.mock.calls[0][0](portA); 107 | chrome.runtime.onConnect.addListener.mock.calls[0][0](portB); 108 | rerender(); 109 | expect(portA.onMessage.removeListener.mock.calls.length).toBe(0); 110 | expect(portB.onMessage.removeListener.mock.calls.length).toBe(0); 111 | unmount(); 112 | expect(portA.onMessage.removeListener.mock.calls.length).toBe(1); 113 | expect(portA.onMessage.removeListener.mock.calls[0][0]).toBe( 114 | portA.onMessage.addListener.mock.calls[0][0] 115 | ); 116 | expect(portB.onMessage.removeListener.mock.calls.length).toBe(1); 117 | expect(portB.onMessage.removeListener.mock.calls[0][0]).toBe( 118 | portB.onMessage.addListener.mock.calls[0][0] 119 | ); 120 | }); 121 | -------------------------------------------------------------------------------- /src/scripts/lib/hooks/useSyncedStateFromBackground.ts: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useEffect } from "react"; 2 | 3 | import { usePorts, Port } from "./usePorts"; 4 | import { messagingServiceFactory } from "../messaging"; 5 | import { useDisposables } from "./useDisposables"; 6 | 7 | export function useSyncedStateFromBackground( 8 | initialState: State, 9 | logger = console.log 10 | ): [State, React.Dispatch>] { 11 | // The app state 12 | const [state, updateState] = useState(initialState); 13 | 14 | // Must dispose of listeners when we close 15 | const dispose = useDisposables(); 16 | 17 | // To prevent circular updates 18 | const justUpdatedPort = useRef(undefined); 19 | 20 | // Typesafe messaging library 21 | const { current: messaging } = useRef(messagingServiceFactory()); 22 | 23 | // Listen for ports connecting 24 | const { ports, lastConnected } = usePorts(logger); 25 | 26 | // Only run this once a port connnects 27 | useEffect( 28 | () => { 29 | if (!lastConnected) return; 30 | 31 | logger("callback detected a port just connected", ports.length); 32 | 33 | const disposable = messaging.listenForMessage(lastConnected, "state-update", msg => { 34 | logger("updating state.."); 35 | justUpdatedPort.current = lastConnected; 36 | updateState(msg.payload); 37 | }); 38 | 39 | dispose(disposable); 40 | 41 | messaging.updateState(lastConnected, state); 42 | }, 43 | [lastConnected] 44 | ); 45 | 46 | // Run this if the state changes 47 | useEffect( 48 | () => { 49 | for (const p of ports) 50 | if (p != justUpdatedPort.current && !p.isDisconnected) messaging.updateState(p, state); 51 | justUpdatedPort.current = undefined; 52 | }, 53 | [state] 54 | ); 55 | 56 | return [state, updateState]; 57 | } 58 | -------------------------------------------------------------------------------- /src/scripts/lib/hooks/useSyncedStateFromChildPage.test.tsx: -------------------------------------------------------------------------------- 1 | import { portFactory } from "../../test/utils"; 2 | 3 | const port = portFactory(); 4 | 5 | const chrome = { 6 | runtime: { 7 | onConnect: { 8 | addListener: jest.fn(), 9 | removeListener: jest.fn() 10 | }, 11 | connect: jest.fn().mockReturnValue(port) 12 | } 13 | }; 14 | 15 | global["chrome"] = chrome; 16 | 17 | import * as React from "react"; 18 | import { render } from "react-testing-library"; 19 | import { usePorts, resetPortId } from "./usePorts"; 20 | import { useSyncedStateFromChildPage } from "./useSyncedStateFromChildPage"; 21 | 22 | let hook: ReturnType | undefined; 23 | 24 | const initialState = { 25 | a: "foo" 26 | }; 27 | 28 | function SomeComponent() { 29 | hook = useSyncedStateFromChildPage("zzz", initialState); 30 | return
hello
; 31 | } 32 | 33 | beforeEach(() => { 34 | hook = undefined; 35 | jest.clearAllMocks(); 36 | resetPortId(); 37 | }); 38 | 39 | it("should initialize to the expected values", () => { 40 | const { rerender } = render(); 41 | rerender(); 42 | expect(hook![0]).toEqual({ a: "foo" }); 43 | }); 44 | 45 | it("should be able to have its state updated", () => { 46 | const { rerender } = render(); 47 | rerender(); 48 | hook![1]({ a: "bar" }); 49 | expect(hook![0]).toEqual({ a: "bar" }); 50 | }); 51 | 52 | it("should be able to have its state partially updated", () => { 53 | const { rerender } = render(); 54 | rerender(); 55 | hook![1]({ a: "bar", b: "bee" }); 56 | expect(hook![0]).toEqual({ a: "bar", b: "bee" }); 57 | hook![1]({ b: "derp" }); 58 | expect(hook![0]).toEqual({ a: "bar", b: "derp" }); 59 | }); 60 | 61 | it("should update its state when the port sends a state update message", () => { 62 | const { rerender } = render(); 63 | rerender(); 64 | port.onMessage.addListener.mock.calls[0][0]({ type: "state-update", payload: { a: "derp" } }); 65 | rerender(); 66 | expect(hook![0]).toEqual({ a: "derp" }); 67 | }); 68 | 69 | it("should stop listening to the port when unmounted", () => { 70 | const { unmount, rerender } = render(); 71 | rerender(); 72 | expect(port.onMessage.removeListener.mock.calls.length).toBe(0); 73 | unmount(); 74 | expect(port.onMessage.removeListener.mock.calls.length).toBe(1); 75 | expect(port.onMessage.removeListener.mock.calls[0][0]).toBe( 76 | port.onMessage.addListener.mock.calls[0][0] 77 | ); 78 | }); 79 | 80 | it("should disconnect the port when unmounted", () => { 81 | const { unmount, rerender } = render(); 82 | rerender(); 83 | expect(port.disconnect.mock.calls.length).toBe(0); 84 | unmount(); 85 | expect(port.disconnect.mock.calls.length).toBe(1); 86 | }); 87 | -------------------------------------------------------------------------------- /src/scripts/lib/hooks/useSyncedStateFromChildPage.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from "react"; 2 | import { messagingServiceFactory } from "../messaging"; 3 | 4 | let id = 0; 5 | 6 | export function useSyncedStateFromChildPage( 7 | pageName: string, 8 | initialState: State 9 | ): [State, (state: Partial) => void] { 10 | const [state, update] = useState(initialState); 11 | const [port] = useState(() => chrome.runtime.connect({ name: pageName + id++ })); 12 | const { current: messaging } = useRef(messagingServiceFactory()); 13 | 14 | useEffect(() => { 15 | const dispose = messaging.listenForMessage(port, "state-update", msg => update(msg.payload)); 16 | return () => { 17 | dispose(); 18 | port.disconnect(); 19 | }; 20 | }, []); 21 | 22 | return [ 23 | state, 24 | (s: Partial) => { 25 | const newState: State = { ...state, ...s }; 26 | messaging.updateState(port, newState); 27 | update(newState); 28 | } 29 | ]; 30 | } 31 | -------------------------------------------------------------------------------- /src/scripts/lib/messaging.ts: -------------------------------------------------------------------------------- 1 | export type MessagesFromPayloads = { [K in keyof T]: { type: string; payload: T[K] } }; 2 | 3 | export type LibMessagePayloads = { 4 | "state-update": TExtensionState; 5 | }; 6 | 7 | export type LibMessages = MessagesFromPayloads>; 8 | 9 | export const messagingServiceFactory = () => { 10 | type Payloads = LibMessagePayloads & UserMessagePayloads; 11 | type Messages = LibMessages & MessagesFromPayloads; 12 | type Port = chrome.runtime.Port; 13 | 14 | const postMessage = ( 15 | port: Port, 16 | type: MessageType, 17 | payload: Payloads[MessageType] 18 | ) => port.postMessage({ type, payload }); 19 | 20 | const listenForMessage = ( 21 | port: Port, 22 | type: MessageType, 23 | listener: (msg: Messages[MessageType]) => void 24 | ) => { 25 | function onMessage(msg: any) { 26 | if (msg.type == type) listener(msg); 27 | } 28 | port.onMessage.addListener(onMessage); 29 | return () => port.onMessage.removeListener(onMessage); 30 | }; 31 | 32 | const updateState = (port: Port, payload: Payloads["state-update"]) => 33 | postMessage(port, "state-update", payload); 34 | 35 | return { 36 | postMessage, 37 | updateState, 38 | listenForMessage 39 | }; 40 | }; 41 | 42 | export const messaging = messagingServiceFactory(); 43 | -------------------------------------------------------------------------------- /src/scripts/settings/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as ReactDOM from "react-dom"; 3 | import { ChildPageApp } from "../common/ChildPageApp"; 4 | 5 | console.log("hello from the settings page.."); 6 | 7 | ReactDOM.render(, document.getElementById("root")); 8 | -------------------------------------------------------------------------------- /src/scripts/test/utils.ts: -------------------------------------------------------------------------------- 1 | export function mockGlobal(name: string, value: any) { 2 | global[name] = value; 3 | } 4 | 5 | export const portFactory = () => ({ 6 | name: "aaa", 7 | postMessage: jest.fn(), 8 | disconnect: jest.fn(), 9 | onMessage: { 10 | addListener: jest.fn(), 11 | removeListener: jest.fn() 12 | }, 13 | onDisconnect: { 14 | addListener: jest.fn(), 15 | removeListener: jest.fn() 16 | } 17 | }); 18 | -------------------------------------------------------------------------------- /tsconfig-webpack.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es5", 5 | "esModuleInterop": true, 6 | "declaration": false 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "sourceMap": true, 5 | "module": "commonjs", 6 | "target": "es6", 7 | "lib": ["dom", "es2015", "es2016", "es2017"], 8 | "jsx": "react", 9 | "moduleResolution": "node", 10 | "experimentalDecorators": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "noImplicitReturns": true, 13 | "noImplicitThis": true, 14 | "noImplicitAny": true, 15 | "strictNullChecks": true, 16 | "suppressImplicitAnyIndexErrors": true 17 | }, 18 | "include": ["src"] 19 | } 20 | -------------------------------------------------------------------------------- /webpack.config.ts: -------------------------------------------------------------------------------- 1 | import { Configuration, DefinePlugin } from "webpack"; 2 | 3 | const nodeEnv = process.env.NODE_ENV || "development"; 4 | const isProd = nodeEnv == "production"; 5 | 6 | const config: Configuration = { 7 | entry: { 8 | background: "./src/scripts/background/index.tsx", 9 | settings: "./src/scripts/settings/index.tsx", 10 | contentScript: "./src/scripts/contentScript/index.tsx", 11 | browserAction: "./src/scripts/browserAction/index.tsx" 12 | }, 13 | output: { 14 | filename: "[name]-bundle.js", 15 | path: __dirname + "/dist" 16 | }, 17 | 18 | mode: isProd ? "production" : "development", 19 | 20 | devtool: isProd ? undefined : "source-map", 21 | 22 | resolve: { 23 | extensions: [".ts", ".tsx", ".js", ".json"] 24 | }, 25 | 26 | module: { 27 | rules: [ 28 | { test: /\.tsx?$/, loader: "awesome-typescript-loader" }, 29 | { enforce: "pre", test: /\.js$/, loader: "source-map-loader" } 30 | ] 31 | }, 32 | 33 | plugins: [ 34 | // new LiveReloadPlugin(), 35 | // new CheckerPlugin(), 36 | new DefinePlugin({ 37 | "process.env": { 38 | NODE_ENV: JSON.stringify(nodeEnv) 39 | } 40 | }) 41 | ] 42 | }; 43 | 44 | export default config; 45 | --------------------------------------------------------------------------------