├── testing ├── project │ ├── src │ │ ├── injection │ │ ├── react-app-env.d.ts │ │ ├── di.ts │ │ ├── App.test.tsx │ │ ├── inversify.config.ts │ │ ├── index.css │ │ ├── App.css │ │ ├── sample-service.ts │ │ ├── index.tsx │ │ ├── App.tsx │ │ ├── Internal.tsx │ │ ├── logo.svg │ │ └── serviceWorker.ts │ ├── .env │ ├── public │ │ ├── favicon.ico │ │ ├── manifest.json │ │ └── index.html │ ├── .gitignore │ ├── tsconfig.json │ ├── package.json │ └── README.md └── helpers.ts ├── .gitattributes ├── tsconfig.test.json ├── .npmignore ├── src ├── index.ts ├── index.spec.ts ├── reactive-service.ts ├── reactive-service.spec.ts ├── use-injection.ts ├── use-injection.spec.tsx ├── state-tracker.ts ├── state-tracker.spec.ts ├── create-injection.tsx └── create-injection.spec.tsx ├── .editorconfig ├── .gitignore ├── .travis.yml ├── tsconfig.json ├── LICENSE ├── Takefile ├── tslint.json ├── package.json └── README.md /testing/project/src/injection: -------------------------------------------------------------------------------- 1 | ../../../lib -------------------------------------------------------------------------------- /testing/project/.env: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK=true 2 | -------------------------------------------------------------------------------- /testing/project/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # remove test project from repo stats 2 | testing/project/* linguist-detectable=false 3 | -------------------------------------------------------------------------------- /testing/project/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/luvies/react-injection/HEAD/testing/project/public/favicon.ico -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | "src/**/*.spec.ts", 5 | "src/**/*.spec.tsx" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .vscode/ 2 | src/ 3 | .editorconfig 4 | tsconfig.json 5 | tsconfig.test.json 6 | tslint.json 7 | Takefile 8 | coverage/ 9 | testing/ 10 | -------------------------------------------------------------------------------- /testing/project/src/di.ts: -------------------------------------------------------------------------------- 1 | import { createInjection } from './injection'; 2 | 3 | export const { InjectionProvider, injectComponent } = createInjection(); 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-injection'; 2 | export * from './reactive-service'; 3 | export * from './state-tracker'; 4 | export * from './use-injection'; 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_size = 2 5 | indent_style = space 6 | insert_final_newline = true 7 | 8 | [*.{sql,py,sh}] 9 | indent_size = 4 10 | -------------------------------------------------------------------------------- /testing/project/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /testing/project/src/inversify.config.ts: -------------------------------------------------------------------------------- 1 | import { Container } from 'inversify'; 2 | import { SampleService } from './sample-service'; 3 | 4 | export function configure() { 5 | const container = new Container({ 6 | defaultScope: 'Singleton', 7 | }); 8 | 9 | container.bind(SampleService).toSelf(); 10 | 11 | return container; 12 | } 13 | -------------------------------------------------------------------------------- /src/index.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | 3 | import * as test from './index'; 4 | 5 | // Small test validating that the index exports the right things 6 | it('should export all relevant definitions', () => { 7 | expect(typeof test.ReactiveService).toBe('function'); 8 | expect(typeof test.StateTracker).toBe('function'); 9 | expect(typeof test.createInjection).toBe('function'); 10 | }); 11 | -------------------------------------------------------------------------------- /testing/project/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # build 12 | /lib 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /testing/project/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | sudo: false 3 | node_js: 4 | - "10" 5 | - "12" 6 | - "14" 7 | cache: 8 | npm: false 9 | directories: 10 | - "~/.pnpm-store" 11 | before_install: 12 | - curl -L https://raw.githubusercontent.com/pnpm/self-installer/master/install.js | node 13 | - pnpm config set store-dir ~/.pnpm-store 14 | install: 15 | - pnpm install 16 | script: 17 | - take lint 18 | - take test 19 | after_success: take test:coverage 20 | -------------------------------------------------------------------------------- /testing/project/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 5 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 6 | sans-serif; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | } 10 | 11 | code { 12 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 13 | monospace; 14 | } 15 | -------------------------------------------------------------------------------- /src/reactive-service.ts: -------------------------------------------------------------------------------- 1 | import { inject, injectable } from 'inversify'; 2 | import { AfterFn, StateTracker, StateUpdater } from './state-tracker'; 3 | 4 | @injectable() 5 | export abstract class ReactiveService { 6 | protected abstract state: TState; 7 | 8 | @inject(StateTracker) 9 | private stateTracker!: StateTracker; 10 | 11 | protected setState(updater: StateUpdater, after?: AfterFn): void { 12 | // @ts-ignore 13 | this.stateTracker.enqueueUpdate({ 14 | // @ts-ignore 15 | service: this, 16 | updater, 17 | after, 18 | }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /testing/project/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | animation: App-logo-spin infinite 20s linear; 7 | height: 40vmin; 8 | } 9 | 10 | .App-header { 11 | background-color: #282c34; 12 | min-height: 100vh; 13 | display: flex; 14 | flex-direction: column; 15 | align-items: center; 16 | justify-content: center; 17 | font-size: calc(10px + 2vmin); 18 | color: white; 19 | } 20 | 21 | .App-link { 22 | color: #61dafb; 23 | } 24 | 25 | @keyframes App-logo-spin { 26 | from { 27 | transform: rotate(0deg); 28 | } 29 | to { 30 | transform: rotate(360deg); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /testing/project/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "es2017", 6 | "dom" 7 | ], 8 | "allowJs": true, 9 | "skipLibCheck": false, 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "strict": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "module": "esnext", 15 | "moduleResolution": "node", 16 | "resolveJsonModule": true, 17 | "noEmit": true, 18 | "jsx": "preserve", 19 | "experimentalDecorators": true, 20 | "isolatedModules": true 21 | }, 22 | "include": [ 23 | "src", 24 | "../../src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /testing/project/src/sample-service.ts: -------------------------------------------------------------------------------- 1 | import { injectable } from 'inversify'; 2 | import { ReactiveService } from './injection'; 3 | 4 | interface State { 5 | sample: string; 6 | show: boolean; 7 | } 8 | 9 | @injectable() 10 | export class SampleService extends ReactiveService { 11 | protected state: State = { 12 | sample: 'test', 13 | show: true, 14 | }; 15 | 16 | public get sample(): string { 17 | return this.state.sample; 18 | } 19 | 20 | public get show(): boolean { 21 | return this.state.show; 22 | } 23 | 24 | public setSample(sample: string) { 25 | this.setState({ 26 | sample, 27 | }); 28 | } 29 | 30 | public setShow(show: boolean) { 31 | this.setState({ 32 | show, 33 | }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /testing/project/src/index.tsx: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | import App from './App'; 6 | import { InjectionProvider } from './di'; 7 | import './index.css'; 8 | import { configure } from './inversify.config'; 9 | import * as serviceWorker from './serviceWorker'; 10 | 11 | const container = configure(); 12 | 13 | ReactDOM.render( 14 | 15 | 16 | , 17 | document.getElementById('root'), 18 | ); 19 | 20 | // If you want your app to work offline and load faster, you can change 21 | // unregister() to register() below. Note this comes with some pitfalls. 22 | // Learn more about service workers: http://bit.ly/CRA-PWA 23 | serviceWorker.unregister(); 24 | -------------------------------------------------------------------------------- /testing/project/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test-project", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@types/jest": "26.0.5", 7 | "@types/node": "14.0.23", 8 | "@types/react": "16.9.43", 9 | "@types/react-dom": "16.9.8", 10 | "inversify": "^5.0.1", 11 | "react": "^16.9.0", 12 | "react-dom": "^16.9.0", 13 | "react-scripts": "3.4.1", 14 | "reflect-metadata": "^0.1.13", 15 | "typescript": "3.9.7" 16 | }, 17 | "scripts": { 18 | "start": "react-scripts start", 19 | "build": "react-scripts build", 20 | "test": "react-scripts test", 21 | "eject": "react-scripts eject" 22 | }, 23 | "eslintConfig": { 24 | "extends": "react-app" 25 | }, 26 | "browserslist": [ 27 | ">0.2%", 28 | "not dead", 29 | "not ie <= 11", 30 | "not op_mini all" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /testing/project/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import './App.css'; 3 | import { injectComponent } from './di'; 4 | import { InjectableProps } from './injection'; 5 | import Internal from './Internal'; 6 | import { SampleService } from './sample-service'; 7 | 8 | interface Props { 9 | sample: SampleService; 10 | } 11 | 12 | class App extends Component { 13 | public render() { 14 | return ( 15 |
16 |

{this.props.sample.sample}

17 | 18 | {this.props.sample.show && 19 | 20 | } 21 |
22 | ); 23 | } 24 | 25 | private handleClick = () => { 26 | this.props.sample.setSample('new'); 27 | } 28 | } 29 | 30 | export default injectComponent>({ 31 | sample: SampleService, 32 | })(App); 33 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "outDir": "lib", 5 | "lib": ["es2017", "dom"], 6 | "jsx": "react", 7 | "rootDir": "src", 8 | "target": "es2017", 9 | "module": "commonjs", 10 | "declaration": true, 11 | "declarationMap": true, 12 | "sourceMap": true, 13 | "strict": true, 14 | "noImplicitAny": true, 15 | "noImplicitReturns": true, 16 | "noImplicitThis": true, 17 | "strictNullChecks": true, 18 | "alwaysStrict": true, 19 | "moduleResolution": "node", 20 | "esModuleInterop": true, 21 | "forceConsistentCasingInFileNames": true, 22 | "importHelpers": true, 23 | "noUnusedLocals": true, 24 | "experimentalDecorators": true, 25 | "emitDecoratorMetadata": true, 26 | "noUnusedParameters": true, 27 | "allowSyntheticDefaultImports": true, 28 | "resolveJsonModule": true 29 | }, 30 | "include": [ 31 | "src/**/*.ts", 32 | "src/**/*.tsx" 33 | ], 34 | "exclude": [ 35 | "node_modules", 36 | "src/**/*.spec.ts", 37 | "src/**/*.spec.tsx" 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 luvies 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 | -------------------------------------------------------------------------------- /testing/project/src/Internal.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { injectComponent } from './di'; 3 | import { InjectableProps } from './injection'; 4 | import { SampleService } from './sample-service'; 5 | 6 | interface Props { 7 | sample: SampleService; 8 | } 9 | 10 | interface State { 11 | text: string; 12 | } 13 | 14 | class Internal extends Component { 15 | public state: State = { 16 | text: 'base', 17 | }; 18 | 19 | private cancelUpdate = false; 20 | 21 | public componentWillUnmount() { 22 | this.cancelUpdate = true; 23 | } 24 | 25 | public render() { 26 | return ( 27 | <> 28 |

{this.state.text}

29 | 30 | 31 | ); 32 | } 33 | 34 | private handleClick = () => { 35 | Promise.resolve() 36 | .then(() => { 37 | this.props.sample.setShow(false); 38 | this.setState({ 39 | text: 'change', 40 | }); 41 | }); 42 | } 43 | } 44 | 45 | export default injectComponent>({ 46 | sample: SampleService, 47 | })(Internal); 48 | -------------------------------------------------------------------------------- /src/reactive-service.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | 3 | import { Container } from 'inversify'; 4 | import { flushPromises } from '../testing/helpers'; 5 | import { ReactiveService } from './reactive-service'; 6 | import { StateTracker } from './state-tracker'; 7 | 8 | interface SampleState { 9 | test: string; 10 | } 11 | 12 | class SampleService extends ReactiveService { 13 | protected state: SampleState = { 14 | test: 'testing', 15 | }; 16 | 17 | public get test(): string { 18 | return this.state.test; 19 | } 20 | 21 | public setTest(test: string): void { 22 | this.setState({ 23 | test, 24 | }); 25 | } 26 | } 27 | 28 | let service: SampleService; 29 | 30 | beforeEach(() => { 31 | service = new SampleService(); 32 | }); 33 | 34 | it('enqueues an update on setState', async () => { 35 | // @ts-ignore 36 | service.stateTracker = new StateTracker(); 37 | 38 | expect(service.test).toBe('testing'); 39 | 40 | service.setTest('new value'); 41 | 42 | await flushPromises(); 43 | 44 | expect(service.test).toBe('new value'); 45 | }); 46 | 47 | it('should work in inversify', () => { 48 | const container = new Container(); 49 | container.bind(StateTracker).toSelf().inSingletonScope(); 50 | container.bind(SampleService).toSelf(); 51 | 52 | const sampleService = container.get(SampleService); 53 | expect(sampleService).toBeInstanceOf(SampleService); 54 | }); 55 | -------------------------------------------------------------------------------- /src/use-injection.ts: -------------------------------------------------------------------------------- 1 | import { Container } from 'inversify'; 2 | import { Context, useContext, useEffect, useMemo, useState } from 'react'; 3 | import { InjectableProps } from './create-injection'; 4 | import { StateTracker } from './state-tracker'; 5 | 6 | export function useInjection( 7 | context: Context | Context, 8 | inject: InjectableProps, 9 | ): T { 10 | const container = useContext(context as any); 11 | 12 | if (!container) { 13 | throw new Error('No container was provided in context'); 14 | } 15 | 16 | // Use an empty state to allow us to trigger updates on command. 17 | const [, setTrigger] = useState({}); 18 | 19 | // After render, start listening to state updates. 20 | // We also need to stop listening on cleanup. 21 | useEffect(() => { 22 | const doUpdate = () => setTrigger({}); 23 | 24 | let stateTracker: StateTracker | undefined; 25 | if (container.isBound(StateTracker)) { 26 | stateTracker = container.get(StateTracker); 27 | stateTracker.handlers.add(doUpdate); 28 | } 29 | 30 | return () => { 31 | if (stateTracker) { 32 | stateTracker.handlers.delete(doUpdate); 33 | } 34 | }; 35 | }); 36 | 37 | // We don't need to re-get the services if the object defining them hasn't changed. 38 | const services = useMemo(() => { 39 | const srv: T = {} as any; 40 | for (const key of (Object.keys(inject) as Array)) { 41 | srv[key] = container.get(inject[key]); 42 | } 43 | 44 | return srv; 45 | }, [inject]); 46 | 47 | return services; 48 | } 49 | -------------------------------------------------------------------------------- /testing/helpers.ts: -------------------------------------------------------------------------------- 1 | import { Container, inject, injectable } from 'inversify'; 2 | import { ReactiveService } from '../src/reactive-service'; 3 | import { StateTracker } from '../src/state-tracker'; 4 | 5 | export function flushPromises() { 6 | return new Promise(resolve => setImmediate(resolve)); 7 | } 8 | 9 | export const sampleIdent = 'sample-service'; 10 | 11 | interface SampleState { 12 | sample: string; 13 | } 14 | 15 | @injectable() 16 | // @ts-ignore 17 | export class SampleService extends ReactiveService { 18 | protected state: SampleState = { 19 | sample: 'value1', 20 | }; 21 | 22 | public get sample(): string { 23 | return this.state.sample; 24 | } 25 | 26 | public setSample(sample: string): void { 27 | this.setState({ 28 | sample, 29 | }); 30 | } 31 | } 32 | 33 | @injectable() 34 | // @ts-ignore 35 | export class SecondaryService { 36 | public constructor( 37 | // @ts-ignore 38 | @inject(sampleIdent) 39 | private sample: SampleService, 40 | ) { } 41 | 42 | public get test(): string { 43 | return this.sample.sample; 44 | } 45 | 46 | public setTest(sample: string): void { 47 | this.sample.setSample(sample); 48 | } 49 | } 50 | 51 | export function initContainer(synchronous = false) { 52 | const container = new Container(); 53 | StateTracker.bindToContainer(container); 54 | container.bind(sampleIdent).to(SampleService).inSingletonScope(); 55 | container.bind(SecondaryService).toSelf(); 56 | 57 | const stateTracker = container.get(StateTracker); 58 | (stateTracker as any).synchronous = synchronous; 59 | 60 | return container; 61 | } 62 | -------------------------------------------------------------------------------- /testing/project/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | React App 23 | 24 | 25 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /Takefile: -------------------------------------------------------------------------------- 1 | module.exports = (take) => { 2 | take.options.shell.printStdout = true; 3 | take.options.shell.printStderr = true; 4 | 5 | return { 6 | '': { 7 | desc: 'Clean & build', 8 | deps: [ 9 | 'clean', 10 | 'build', 11 | ], 12 | }, 13 | 'build': { 14 | desc: 'Builds the package', 15 | async execute() { 16 | await take.exec('tsc'); 17 | }, 18 | }, 19 | 'clean': { 20 | desc: 'Cleans up build output', 21 | async execute() { 22 | await take.exec('rm -rf lib'); 23 | }, 24 | }, 25 | 'test': { 26 | desc: 'Tests the package', 27 | async execute() { 28 | await take.exec('mkdir -p ./coverage') 29 | await take.exec('FORCE_COLOR=true yarn jest --coverage --coverageReporters=text-lcov > ./coverage/report.txt'); 30 | }, 31 | children: { 32 | 'coverage': { 33 | desc: 'Uploads the coverage report to coveralls', 34 | async execute() { 35 | await take.exec('yarn coveralls < ./coverage/report.txt'); 36 | }, 37 | }, 38 | }, 39 | }, 40 | 'lint': { 41 | desc: 'Lints the src/ folder', 42 | async execute() { 43 | await take.exec('tslint --project .'); 44 | }, 45 | }, 46 | 'fix': { 47 | desc: 'Fixes lint issues in the src/ folder', 48 | async execute() { 49 | await take.exec('tslint --project .'); 50 | }, 51 | }, 52 | 'publish': { 53 | desc: 'Publishes the package to npm', 54 | deps: [ 55 | ':', 56 | ':test', 57 | ], 58 | async execute() { 59 | await take.exec('yarn publish'); 60 | await take.exec('git push origin --tags'); 61 | await take.exec('git push'); 62 | }, 63 | } 64 | }; 65 | }; 66 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint:recommended", 4 | "tslint-config-prettier", 5 | "tslint-react" 6 | ], 7 | "rules": { 8 | "interface-name": false, 9 | "semicolon": true, 10 | "object-literal-sort-keys": false, 11 | "no-console": { 12 | "severity": "warn" 13 | }, 14 | "ordered-imports": { 15 | "severity": "warn" 16 | }, 17 | "jsx-boolean-value": false, 18 | "indent": [ 19 | true, 20 | "spaces", 21 | 2 22 | ], 23 | "trailing-comma": [ 24 | true, 25 | { 26 | "multiline": "always" 27 | } 28 | ], 29 | "whitespace": [ 30 | true, 31 | "check-branch", 32 | "check-decl", 33 | "check-operator", 34 | "check-module", 35 | "check-separator", 36 | "check-rest-spread", 37 | "check-type", 38 | "check-type-operator", 39 | "check-preblock" 40 | ], 41 | "quotemark": [ 42 | true, 43 | "single", 44 | "jsx-double" 45 | ], 46 | "no-return-await": true, 47 | "no-unnecessary-type-assertion": true, 48 | "await-promise": true, 49 | "ban-comma-operator": true, 50 | "no-floating-promises": false, 51 | "no-void-expression": false, 52 | "eofline": true, 53 | "linebreak-style": [ 54 | true, 55 | "LF" 56 | ], 57 | "no-irregular-whitespace": true, 58 | "one-line": [ 59 | true, 60 | "check-catch", 61 | "check-finally", 62 | "check-else", 63 | "check-open-brace", 64 | "check-whitespace" 65 | ], 66 | "prefer-template": true, 67 | "return-undefined": true, 68 | "no-inferrable-types": true, 69 | "member-access": [ 70 | true, 71 | "check-accessor", 72 | "check-constructor", 73 | "check-parameter-property" 74 | ], 75 | "jsx-no-multiline-js": false, 76 | "no-conditional-assignment": false, 77 | "only-arrow-functions": false, 78 | "max-classes-per-file": false, 79 | "array-type": [true, "array-simple"] 80 | }, 81 | "linterOptions": { 82 | "exclude": [ 83 | "Takefile", 84 | "src/**/*.json" 85 | ] 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /testing/project/README.md: -------------------------------------------------------------------------------- 1 | # Test Project 2 | You have to remove the 'isolatedModules' flag from the react-scripts config for this project to work properly. 3 | 4 | 5 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 6 | 7 | ## Available Scripts 8 | 9 | In the project directory, you can run: 10 | 11 | ### `npm start` 12 | 13 | Runs the app in the development mode.
14 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 15 | 16 | The page will reload if you make edits.
17 | You will also see any lint errors in the console. 18 | 19 | ### `npm test` 20 | 21 | Launches the test runner in the interactive watch mode.
22 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 23 | 24 | ### `npm run build` 25 | 26 | Builds the app for production to the `build` folder.
27 | It correctly bundles React in production mode and optimizes the build for the best performance. 28 | 29 | The build is minified and the filenames include the hashes.
30 | Your app is ready to be deployed! 31 | 32 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 33 | 34 | ### `npm run eject` 35 | 36 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 37 | 38 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 39 | 40 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 41 | 42 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 43 | 44 | ## Learn More 45 | 46 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 47 | 48 | To learn React, check out the [React documentation](https://reactjs.org/). 49 | -------------------------------------------------------------------------------- /src/use-injection.spec.tsx: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | 3 | import { Container } from 'inversify'; 4 | import React, { Context, createContext } from 'react'; 5 | import ReactDOM from 'react-dom'; 6 | import { act } from 'react-dom/test-utils'; 7 | import { initContainer, sampleIdent, SampleService, SecondaryService } from '../testing/helpers'; 8 | import { InjectableProps } from './create-injection'; 9 | import { useInjection } from './use-injection'; 10 | 11 | let root: HTMLDivElement; 12 | let container: Container; 13 | let context: Context; 14 | 15 | function useInject(inject: InjectableProps): T { 16 | return useInjection(context, inject); 17 | } 18 | 19 | interface InjectedProps { 20 | sample: SampleService; 21 | secondary: SecondaryService; 22 | } 23 | 24 | function InjectedComponent() { 25 | const { sample, secondary } = useInject({ 26 | sample: sampleIdent, 27 | secondary: SecondaryService, 28 | }); 29 | 30 | return ( 31 | <> 32 |

{sample.sample}

33 |

{secondary.test}

34 | 35 | ); 36 | } 37 | 38 | beforeEach(() => { 39 | root = document.createElement('div'); 40 | document.body.appendChild(root); 41 | 42 | container = initContainer(true); 43 | 44 | context = createContext(container); 45 | }); 46 | 47 | afterEach(() => { 48 | document.body.removeChild(root); 49 | }); 50 | 51 | it('can render and react to updates', () => { 52 | const sampleService: SampleService = container.get(sampleIdent); 53 | const secondaryService = container.get(SecondaryService); 54 | 55 | // Test initial render. 56 | act(() => { 57 | ReactDOM.render(, root); 58 | }); 59 | const sampleElement = root.querySelector('#sample') as HTMLParagraphElement; 60 | const secondaryElement = root.querySelector('#secondary') as HTMLParagraphElement; 61 | expect(sampleElement.textContent).toBe('value1'); 62 | expect(secondaryElement.textContent).toBe('value1'); 63 | 64 | // Set state and test change. 65 | act(() => { 66 | sampleService.setSample('value2'); 67 | }); 68 | 69 | expect(sampleElement.textContent).toBe('value2'); 70 | expect(secondaryElement.textContent).toBe('value2'); 71 | 72 | // Set state and test change 73 | act(() => { 74 | secondaryService.setTest('value3'); 75 | }); 76 | 77 | expect(sampleElement.textContent).toBe('value3'); 78 | expect(secondaryElement.textContent).toBe('value3'); 79 | }); 80 | -------------------------------------------------------------------------------- /testing/project/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-injection", 3 | "version": "1.2.3", 4 | "main": "./lib/index.js", 5 | "license": "MIT", 6 | "repository": "luvies/react-injection", 7 | "peerDependencies": { 8 | "inversify": "^5.0.1", 9 | "react": "^16.8.3" 10 | }, 11 | "devDependencies": { 12 | "@luvies/take": "^0.0.9", 13 | "@types/enzyme": "^3.10.3", 14 | "@types/enzyme-adapter-react-16": "^1.0.5", 15 | "@types/jest": "^26.0.5", 16 | "@types/node": "14.0.23", 17 | "@types/react": "16.9.43", 18 | "@types/react-dom": "^16.9.0", 19 | "coveralls": "^3.0.6", 20 | "enzyme": "^3.10.0", 21 | "enzyme-adapter-react-16": "^1.14.0", 22 | "identity-obj-proxy": "^3.0.0", 23 | "inversify": "^5.0.1", 24 | "jest": "^26.1.0", 25 | "jsdom": "^16.3.0", 26 | "react": "^16.9.0", 27 | "react-app-polyfill": "^1.0.3", 28 | "react-dom": "^16.9.0", 29 | "react-native-web": "^0.13.3", 30 | "reflect-metadata": "^0.1.13", 31 | "ts-jest": "^26.1.3", 32 | "tslib": "^2.0.0", 33 | "tslint": "^6.1.2", 34 | "tslint-config-prettier": "^1.18.0", 35 | "tslint-react": "^5.0.0", 36 | "typescript": "3.9.7" 37 | }, 38 | "keywords": [ 39 | "react", 40 | "dependency", 41 | "injection", 42 | "dependency-injection", 43 | "hook", 44 | "hooks", 45 | "inversify" 46 | ], 47 | "jest": { 48 | "collectCoverageFrom": [ 49 | "src/**/*.{js,jsx,ts,tsx}", 50 | "!**/*.d.ts" 51 | ], 52 | "setupFiles": [ 53 | "react-app-polyfill/jsdom" 54 | ], 55 | "testMatch": [ 56 | "/src/**/__tests__/**/*.(j|t)s?(x)", 57 | "/src/**/?(*.)(spec|test).(j|t)s?(x)" 58 | ], 59 | "testEnvironment": "jsdom", 60 | "testURL": "http://localhost", 61 | "transform": { 62 | "^.+\\.tsx?$": "ts-jest" 63 | }, 64 | "transformIgnorePatterns": [ 65 | "[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs|ts|tsx)$" 66 | ], 67 | "moduleNameMapper": { 68 | "^react-native$": "react-native-web", 69 | "^.+(\\.module)?\\.(css|sass|scss)$": "identity-obj-proxy", 70 | "^components/(.*)": "/src/components/$1", 71 | "^@/(.*)": "/src/$1" 72 | }, 73 | "moduleFileExtensions": [ 74 | "web.ts", 75 | "ts", 76 | "web.tsx", 77 | "tsx", 78 | "web.js", 79 | "js", 80 | "web.jsx", 81 | "jsx", 82 | "json", 83 | "node", 84 | "mjs" 85 | ], 86 | "globals": { 87 | "ts-jest": { 88 | "tsConfig": "tsconfig.test.json" 89 | } 90 | }, 91 | "unmockedModulePathPatterns": [ 92 | "node_modules/react/", 93 | "node_modules/react-dom/", 94 | "node_modules/enzyme/" 95 | ] 96 | }, 97 | "browserslist": [ 98 | ">0.2%", 99 | "not dead", 100 | "not ie <= 11", 101 | "not op_mini all" 102 | ] 103 | } 104 | -------------------------------------------------------------------------------- /src/state-tracker.ts: -------------------------------------------------------------------------------- 1 | import { Container, injectable } from 'inversify'; 2 | 3 | export interface IStatefulService { 4 | state: TState; 5 | } 6 | 7 | export type StateUpdater = Partial | ((prev: TState) => Partial | undefined) | undefined; 8 | 9 | export type AfterFn = (state: TState) => void; 10 | 11 | export type HandlerFn = () => void; 12 | 13 | export interface StateChange { 14 | service: IStatefulService; 15 | updater: StateUpdater; 16 | after?: AfterFn; 17 | } 18 | 19 | @injectable() 20 | export class StateTracker { 21 | public static bindToContainer(container: Container) { 22 | if (!container.isBound(StateTracker)) { 23 | container.bind(StateTracker).toSelf().inSingletonScope(); 24 | } 25 | } 26 | 27 | public handlers = new Set(); 28 | 29 | private changes: Array> = []; 30 | private scheduledUpdate = false; 31 | private synchronous = false; 32 | 33 | public enqueueUpdate(update: StateChange): void { 34 | this.changes.push(update); 35 | 36 | if (!this.synchronous) { 37 | if (!this.scheduledUpdate) { 38 | Promise.resolve().then(() => this.handleUpdate()); 39 | this.scheduledUpdate = true; 40 | } 41 | } else { 42 | this.handleUpdate(); 43 | } 44 | } 45 | 46 | private handleUpdate() { 47 | let change: StateChange | undefined; 48 | let performedChange = false; 49 | const afters: Array<{ 50 | service: IStatefulService, 51 | after: AfterFn, 52 | }> = []; 53 | 54 | while (change = this.changes.shift()) { 55 | let newState: Partial | undefined; 56 | 57 | if (typeof change.updater === 'function') { 58 | newState = change.updater(change.service.state); 59 | } else { 60 | newState = change.updater; 61 | } 62 | 63 | if (!newState) { 64 | continue; 65 | } 66 | 67 | change.service.state = Object.assign( 68 | {}, 69 | change.service.state, 70 | newState, 71 | ); 72 | performedChange = true; 73 | 74 | if (change.after) { 75 | afters.push({ 76 | service: change.service, 77 | after: change.after, 78 | }); 79 | } 80 | } 81 | 82 | this.scheduledUpdate = false; 83 | if (performedChange) { 84 | // Copy all of the handlers to a separate list. 85 | // This is because when each handler is fired, there's a chance 86 | // that the component it's from will unbind & rebind, causing the next 87 | // item in the set to be the same handler. 88 | const handlersCpy: HandlerFn[] = []; 89 | this.handlers.forEach(handler => handlersCpy.push(handler)); 90 | 91 | // Fire all the handlers. 92 | for (const handler of handlersCpy) { 93 | // Since a handler may be unbound as a result of calling 94 | // the previous handlers, we need to double check that the 95 | // handler is still bound. 96 | if (this.handlers.has(handler)) { 97 | handler(); 98 | } 99 | } 100 | 101 | for (const { after, service } of afters) { 102 | after(service.state); 103 | } 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/state-tracker.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | 3 | import { flushPromises } from '../testing/helpers'; 4 | import { IStatefulService, StateTracker } from './state-tracker'; 5 | 6 | interface SampleState { 7 | test: string; 8 | test2: string; 9 | } 10 | 11 | class SampleService implements IStatefulService { 12 | public state: SampleState = { 13 | test: 'testing', 14 | test2: 'testing 2', 15 | }; 16 | } 17 | 18 | let service: SampleService; 19 | let tracker: StateTracker; 20 | let handlerCalls: number; 21 | 22 | function handler() { 23 | handlerCalls++; 24 | } 25 | 26 | function altHandler() { 27 | handlerCalls++; 28 | } 29 | 30 | beforeEach(() => { 31 | service = new SampleService(); 32 | tracker = new StateTracker(); 33 | handlerCalls = 0; 34 | }); 35 | 36 | it('performs an object state change', async () => { 37 | tracker.enqueueUpdate({ 38 | service, 39 | updater: { test: 'new-value' }, 40 | }); 41 | 42 | await flushPromises(); 43 | 44 | expect(service.state).toEqual({ 45 | test: 'new-value', 46 | test2: 'testing 2', 47 | }); 48 | }); 49 | 50 | it('fires connected handlers on a state change', async () => { 51 | tracker.handlers.add(handler); 52 | tracker.enqueueUpdate({ 53 | service, 54 | updater: { test: 'new value' }, 55 | }); 56 | 57 | await flushPromises(); 58 | 59 | expect(handlerCalls).toBe(1); 60 | }); 61 | 62 | it('fires all connected handlers on a state change', async () => { 63 | tracker.handlers.add(handler); 64 | tracker.handlers.add(altHandler); 65 | tracker.enqueueUpdate({ 66 | service, 67 | updater: { test: 'new value' }, 68 | }); 69 | 70 | await flushPromises(); 71 | 72 | expect(handlerCalls).toBe(2); 73 | }); 74 | 75 | it('handles all enqueued updates', async () => { 76 | tracker.enqueueUpdate({ 77 | service, 78 | updater: { test: 'new value' }, 79 | }); 80 | tracker.enqueueUpdate({ 81 | service, 82 | updater: { test2: 'changed value' }, 83 | }); 84 | 85 | await flushPromises(); 86 | 87 | expect(service.state).toEqual({ 88 | test: 'new value', 89 | test2: 'changed value', 90 | }); 91 | }); 92 | 93 | it('handles a function update correctly', async () => { 94 | tracker.enqueueUpdate({ 95 | service, 96 | updater: () => ({ test: 'new value' }), 97 | }); 98 | 99 | await flushPromises(); 100 | 101 | expect(service.state).toEqual({ 102 | test: 'new value', 103 | test2: 'testing 2', 104 | }); 105 | }); 106 | 107 | it('passes the previous state to the update function', async () => { 108 | let prevState: SampleState | undefined; 109 | 110 | tracker.enqueueUpdate({ 111 | service, 112 | updater: { test2: 'changed value' }, 113 | }); 114 | tracker.enqueueUpdate({ 115 | service, 116 | updater: prev => { 117 | prevState = prev; 118 | 119 | return { 120 | test: 'new value', 121 | }; 122 | }, 123 | }); 124 | 125 | await flushPromises(); 126 | 127 | expect(prevState).toEqual({ 128 | test: 'testing', 129 | test2: 'changed value', 130 | }); 131 | }); 132 | 133 | it('calls the after function after the update', async () => { 134 | tracker.enqueueUpdate({ 135 | service, 136 | updater: { test: 'new value' }, 137 | after: handler, 138 | }); 139 | 140 | await flushPromises(); 141 | 142 | expect(handlerCalls).toBe(1); 143 | }); 144 | 145 | it('calls all after functions after the update', async () => { 146 | tracker.enqueueUpdate({ 147 | service, 148 | updater: { test: 'new value' }, 149 | after: handler, 150 | }); 151 | tracker.enqueueUpdate({ 152 | service, 153 | updater: { test2: 'changed value' }, 154 | after: altHandler, 155 | }); 156 | 157 | await flushPromises(); 158 | 159 | expect(handlerCalls).toBe(2); 160 | }); 161 | 162 | it('passes the new state to the after function', async () => { 163 | let newState: SampleState | undefined; 164 | 165 | tracker.enqueueUpdate({ 166 | service, 167 | updater: { test: 'new value' }, 168 | after: state => { 169 | newState = state; 170 | }, 171 | }); 172 | tracker.enqueueUpdate({ 173 | service, 174 | updater: { test2: 'changed value' }, 175 | }); 176 | 177 | await flushPromises(); 178 | 179 | expect(newState).toEqual({ 180 | test: 'new value', 181 | test2: 'changed value', 182 | }); 183 | }); 184 | 185 | it('should not trigger an update if the updater value was undefined', async () => { 186 | tracker.handlers.add(handler); 187 | tracker.enqueueUpdate({ 188 | service, 189 | updater: undefined, 190 | }); 191 | 192 | await flushPromises(); 193 | 194 | expect(handlerCalls).toBe(0); 195 | }); 196 | 197 | it('should not fire the after function if the updater was undefined', async () => { 198 | tracker.enqueueUpdate({ 199 | service, 200 | updater: undefined, 201 | after: handler, 202 | }); 203 | 204 | await flushPromises(); 205 | 206 | expect(handlerCalls).toBe(0); 207 | }); 208 | 209 | it('should not trigger an update if the updater function returned undefined', async () => { 210 | tracker.handlers.add(handler); 211 | tracker.enqueueUpdate({ 212 | service, 213 | updater: () => undefined, 214 | }); 215 | 216 | await flushPromises(); 217 | 218 | expect(handlerCalls).toBe(0); 219 | }); 220 | 221 | it('should not fire the after function if the updater function returned undefined', async () => { 222 | tracker.enqueueUpdate({ 223 | service, 224 | updater: () => undefined, 225 | after: handler, 226 | }); 227 | 228 | await flushPromises(); 229 | 230 | expect(handlerCalls).toBe(0); 231 | }); 232 | -------------------------------------------------------------------------------- /testing/project/src/serviceWorker.ts: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read http://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | type Config = { 24 | onSuccess?: (registration: ServiceWorkerRegistration) => void; 25 | onUpdate?: (registration: ServiceWorkerRegistration) => void; 26 | }; 27 | 28 | export function register(config?: Config) { 29 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 30 | // The URL constructor is available in all browsers that support SW. 31 | const publicUrl = new URL( 32 | (process as { env: { [key: string]: string } }).env.PUBLIC_URL, 33 | window.location.href 34 | ); 35 | if (publicUrl.origin !== window.location.origin) { 36 | // Our service worker won't work if PUBLIC_URL is on a different origin 37 | // from what our page is served on. This might happen if a CDN is used to 38 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 39 | return; 40 | } 41 | 42 | window.addEventListener('load', () => { 43 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 44 | 45 | if (isLocalhost) { 46 | // This is running on localhost. Let's check if a service worker still exists or not. 47 | checkValidServiceWorker(swUrl, config); 48 | 49 | // Add some additional logging to localhost, pointing developers to the 50 | // service worker/PWA documentation. 51 | navigator.serviceWorker.ready.then(() => { 52 | console.log( 53 | 'This web app is being served cache-first by a service ' + 54 | 'worker. To learn more, visit http://bit.ly/CRA-PWA' 55 | ); 56 | }); 57 | } else { 58 | // Is not localhost. Just register service worker 59 | registerValidSW(swUrl, config); 60 | } 61 | }); 62 | } 63 | } 64 | 65 | function registerValidSW(swUrl: string, config?: Config) { 66 | navigator.serviceWorker 67 | .register(swUrl) 68 | .then(registration => { 69 | registration.onupdatefound = () => { 70 | const installingWorker = registration.installing; 71 | if (installingWorker == null) { 72 | return; 73 | } 74 | installingWorker.onstatechange = () => { 75 | if (installingWorker.state === 'installed') { 76 | if (navigator.serviceWorker.controller) { 77 | // At this point, the updated precached content has been fetched, 78 | // but the previous service worker will still serve the older 79 | // content until all client tabs are closed. 80 | console.log( 81 | 'New content is available and will be used when all ' + 82 | 'tabs for this page are closed. See http://bit.ly/CRA-PWA.' 83 | ); 84 | 85 | // Execute callback 86 | if (config && config.onUpdate) { 87 | config.onUpdate(registration); 88 | } 89 | } else { 90 | // At this point, everything has been precached. 91 | // It's the perfect time to display a 92 | // "Content is cached for offline use." message. 93 | console.log('Content is cached for offline use.'); 94 | 95 | // Execute callback 96 | if (config && config.onSuccess) { 97 | config.onSuccess(registration); 98 | } 99 | } 100 | } 101 | }; 102 | }; 103 | }) 104 | .catch(error => { 105 | console.error('Error during service worker registration:', error); 106 | }); 107 | } 108 | 109 | function checkValidServiceWorker(swUrl: string, config?: Config) { 110 | // Check if the service worker can be found. If it can't reload the page. 111 | fetch(swUrl) 112 | .then(response => { 113 | // Ensure service worker exists, and that we really are getting a JS file. 114 | const contentType = response.headers.get('content-type'); 115 | if ( 116 | response.status === 404 || 117 | (contentType != null && contentType.indexOf('javascript') === -1) 118 | ) { 119 | // No service worker found. Probably a different app. Reload the page. 120 | navigator.serviceWorker.ready.then(registration => { 121 | registration.unregister().then(() => { 122 | window.location.reload(); 123 | }); 124 | }); 125 | } else { 126 | // Service worker found. Proceed as normal. 127 | registerValidSW(swUrl, config); 128 | } 129 | }) 130 | .catch(() => { 131 | console.log( 132 | 'No internet connection found. App is running in offline mode.' 133 | ); 134 | }); 135 | } 136 | 137 | export function unregister() { 138 | if ('serviceWorker' in navigator) { 139 | navigator.serviceWorker.ready.then(registration => { 140 | registration.unregister(); 141 | }); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Dependency Injection 2 | [![Build Status](https://travis-ci.com/luvies/react-injection.svg?branch=master)](https://travis-ci.com/luvies/react-injection) [![Coverage Status](https://coveralls.io/repos/github/luvies/react-injection/badge.svg?branch=master)](https://coveralls.io/github/luvies/react-injection?branch=master) 3 | 4 | Provides a dependency injection system for React using InversifyJS. Each service can inherit the class `ReactiveService` to allow them to trigger component updates when their state changes, allowing for components to use service data in their render functions and respond to changes. 5 | 6 | This package provides both a HOC and a `useInjection` hook. 7 | 8 | ## Example Guide 9 | To define a service, you need to define a class similar to this: 10 | 11 | ```ts 12 | import { injectable } from 'inversify'; 13 | import { ReactiveService } from 'react-injection'; 14 | 15 | interface State { 16 | data: string; 17 | } 18 | 19 | @injectable() 20 | export class DataService extends ReactiveService { 21 | protected state: State = { 22 | data: 'sample data', 23 | }; 24 | 25 | public get data(): string { 26 | return this.state.data; 27 | } 28 | 29 | public setData(data: string): void { 30 | this.setState({ 31 | data, 32 | }); 33 | } 34 | } 35 | ``` 36 | 37 | You can then create an Inversify container with this service bound to it, and define a module that provides the provider component, HOC decorator, and the hook. 38 | 39 | ```ts 40 | // injection.ts 41 | import { createInjection } from 'react-injection'; 42 | 43 | export { InjectionProvider, injectComponent, useInject } = createInjection(); 44 | ``` 45 | 46 | You can then consume the service from your components like so: 47 | 48 | ```tsx 49 | import React from 'react'; 50 | import { injectComponent } from './injection'; 51 | import { InjectableProps } from 'react-injection'; 52 | // This is assuming that the container is set up using the TYPES 53 | // style from the InversifyJS docs. 54 | import { TYPES } from './types'; 55 | 56 | interface InjectedProps { 57 | // You could also name this just 'data' for simplicity. 58 | dataService: DataService; 59 | } 60 | 61 | function App({ dataService }: InjectedProps) { 62 | return ( 63 |

{dataService.data}

64 | ); 65 | } 66 | 67 | export default injectComponent({ 68 | dataService: TYPES.DataService 69 | })(App); 70 | ``` 71 | 72 | Note: `injectComponent` should be usable as a decorator, however TypeScript currently doesn't allow decorators to change the decorated definition's typing currently (since this function removes the injected props from the components typing). If you use babel and JSX/JS, then it should work fine (although I haven't tested this). 73 | 74 | Once you have this set up, you can provide the container using the provider component: 75 | 76 | ```tsx 77 | ReactDOM.render( 78 | 79 | 80 | , 81 | element 82 | ); 83 | ``` 84 | 85 | ### State mapping 86 | You can map service states directly to props using the second param of `injectComponent`, which takes in a function that receives all of the injected services, and return an object to map into props. Example: 87 | 88 | ```tsx 89 | interface InjectedProps { 90 | dataService: DataService; 91 | } 92 | 93 | interface InjectedStateProps { 94 | data: string; 95 | } 96 | 97 | function App({ data }: InjectedProps & InjectedStateProps) { 98 | return ( 99 |

{data}

100 | ); 101 | } 102 | 103 | export default injectComponent( 104 | { 105 | dataService: TYPES.DataService 106 | }, 107 | ({ dataService }) => ({ 108 | data: dataService.data, 109 | }) 110 | )(App); 111 | ``` 112 | 113 | Keep note, the services are injected regardless of whether you use the state mapper or not. It is mostly a helper to allow more direct access to service state & allow proper diffing in `componentDidUpdate(...)`. 114 | 115 | ## Passing container as props directly 116 | The `injectComponent` decorator supports containers being passed directly as the prop `container`, however, if you do this, note that you **_MUST_** bind the `StateTracker` class like so: 117 | 118 | ```ts 119 | // Import using a similar statement to this 120 | import { StateTracker } from 'react-injection'; 121 | 122 | // Bind the class manually 123 | StateTracker.bindToContainer(container); 124 | ``` 125 | 126 | You need to do this whenever you do not use the `InjectionProvider` component provided in `createInjection`. 127 | 128 | ## Hook 129 | To use the hook, you can do something like the following: 130 | 131 | ```tsx 132 | // Imports from this module used in the example. 133 | import { useInjection, InjectableProps, StateTracker } from 'react-injection'; 134 | 135 | // Configure the container from somewhere. 136 | const container = configureContainer(); 137 | 138 | // Create the React context. 139 | // You can also use the context returned from `createInjection` if you plan to 140 | // mix both kinds. 141 | const context = createContext(container); 142 | 143 | // If you use the provider directly, instead of the one given in `createInjection`, 144 | // then you need to remember to do the following. 145 | StateTracker.bindToContainer(container); 146 | 147 | // Consume the services in the component. 148 | interface InjectedProps { 149 | dataService: DataService; 150 | } 151 | 152 | // If you define this object outside of the component, 153 | // it will be re-used for each render, and `useInjection` 154 | // will skip re-fetching the same services multiple times 155 | // (this is implmented via `useMemo`). 156 | // You can still use it inline if you want. 157 | const services: InjectableProps = { 158 | dataService: TYPES.DataService, 159 | } 160 | 161 | function App() { 162 | const { dataService } = useInjection(context, services); 163 | const data = dateService.data; 164 | 165 | return ( 166 |

{data}

167 | ); 168 | } 169 | ``` 170 | 171 | You can also use the `useInject` function provided in `createInjection`. Doing so would mean the App component would look like this: 172 | 173 | ```tsx 174 | function App() { 175 | const { dataService } = useInject(services); 176 | const data = dateService.data; 177 | 178 | return ( 179 |

{data}

180 | ); 181 | } 182 | ``` 183 | -------------------------------------------------------------------------------- /src/create-injection.tsx: -------------------------------------------------------------------------------- 1 | import { Container, interfaces as inversifyTypes } from 'inversify'; 2 | import React, { Component, ComponentType, createContext, ReactNode } from 'react'; 3 | import { StateTracker } from './state-tracker'; 4 | import { useInjection } from './use-injection'; 5 | 6 | // ------ react-redux type definitions ------ 7 | 8 | type Omit = Pick>; 9 | 10 | /** 11 | * a property P will be present if : 12 | * - it is present in both DecorationTargetProps and InjectedProps 13 | * - InjectedProps[P] can satisfy DecorationTargetProps[P] 14 | * ie: decorated component can accept more types than decorator is injecting 15 | * 16 | * For decoration, inject props or ownProps are all optionally 17 | * required by the decorated (right hand side) component. 18 | * But any property required by the decorated component must be satisfied by the injected property. 19 | */ 20 | type Shared< 21 | InjectedProps, 22 | DecorationTargetProps extends Shared 23 | > = { 24 | [P in Extract]?: InjectedProps[P] extends DecorationTargetProps[P] ? DecorationTargetProps[P] : never; 25 | }; 26 | 27 | 28 | // ------ Injection ------ 29 | 30 | export type InjectConfig = Record>; 31 | 32 | export type InjectableProps = { 33 | [K in keyof T]: inversifyTypes.ServiceIdentifier; 34 | }; 35 | 36 | interface ProviderProps { 37 | container: Container; 38 | children: ReactNode; 39 | } 40 | 41 | /** 42 | * Creates an object that contains a provider component that can be used to pass 43 | * the container down to child components, and an injector decorator that can be used 44 | * to inject services into a component. 45 | */ 46 | export function createInjection(defaultContainer?: Container) { 47 | // Create a react context to allow sharing of the given container. 48 | const context = createContext(defaultContainer); 49 | const { Provider, Consumer } = context; 50 | 51 | return { 52 | /** 53 | * The context of the injection. 54 | */ 55 | context, 56 | 57 | /** 58 | * Provides child components with the given container. 59 | */ 60 | InjectionProvider: class InjectionProvider extends React.Component { 61 | public constructor(props: ProviderProps) { 62 | super(props); 63 | 64 | // Make sure StateTracker has been bound to the current container. 65 | StateTracker.bindToContainer(props.container); 66 | } 67 | 68 | public render() { 69 | return {this.props.children}; 70 | } 71 | }, 72 | 73 | /** 74 | * A wrapped version of the `useInjection` hook that uses the current context. 75 | */ 76 | useInject(inject: InjectableProps) { 77 | return useInjection(context, inject); 78 | }, 79 | 80 | /** 81 | * Returns a function that will create a component that will have the requested services 82 | * injected as props. 83 | */ 84 | injectComponent( 85 | inject: InjectableProps, 86 | stateMapper?: (injected: TInject) => TStateMap, 87 | ) { 88 | type RemoveInjectedProps = Omit> & { container?: Container }; 89 | 90 | // Return an full-bodied function to prevent syntax errors due to JSX conflicts. 91 | return function ( 92 | Comp: ComponentType, 93 | ): ComponentType> { 94 | const injector = class extends Component> { 95 | private stateTracker?: StateTracker; 96 | 97 | public componentDidMount() { 98 | this.bindHandlers(); 99 | } 100 | 101 | public componentDidUpdate() { 102 | this.bindHandlers(); 103 | } 104 | 105 | public componentWillUnmount() { 106 | this.unbindHandlers(); 107 | } 108 | 109 | public render() { 110 | return ( 111 | 112 | {container => { 113 | // Extract container from props or consumer. 114 | container = this.props.container || container; 115 | 116 | // Ensure that we got a container from somewhere. 117 | if (!container) { 118 | throw new Error( 119 | 'No container was provided, either provide a default one or use a ContainerProvider component.', 120 | ); 121 | } 122 | 123 | // Unbind handlers first to prevent memory leaks. 124 | this.unbindHandlers(); 125 | 126 | // Get the current state tracker. 127 | if (container.isBound(StateTracker)) { 128 | this.stateTracker = container.get(StateTracker); 129 | } 130 | 131 | // Get the necessary services that we need to inject. 132 | const services: TInject = {} as any; 133 | for (const key of (Object.keys(inject) as Array)) { 134 | services[key] = container.get(inject[key]); 135 | } 136 | 137 | // If given a mapping function, map the service state to props directly. 138 | let stateMap: TStateMap | undefined; 139 | if (stateMapper) { 140 | stateMap = stateMapper(services); 141 | } 142 | 143 | // Init the wrapper component with the given props and services. 144 | // @ts-ignore 145 | return ; 146 | }} 147 | 148 | ); 149 | } 150 | 151 | private bindHandlers() { 152 | if (this.stateTracker) { 153 | this.stateTracker.handlers.add(this.handleUpdate); 154 | } 155 | } 156 | 157 | private unbindHandlers() { 158 | if (this.stateTracker) { 159 | this.stateTracker.handlers.delete(this.handleUpdate); 160 | } 161 | } 162 | 163 | private handleUpdate = () => { 164 | // Trigger render. 165 | this.setState({}); 166 | } 167 | }; 168 | 169 | // Give the component a useful name for debugging. 170 | if (process.env.NODE_ENV === 'development') { 171 | Object.defineProperty(injector, 'name', { 172 | value: `Injector(${Comp.displayName || Comp.name || 'Component'})`, 173 | }); 174 | } 175 | 176 | return injector; 177 | }; 178 | }, 179 | }; 180 | } 181 | -------------------------------------------------------------------------------- /src/create-injection.spec.tsx: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | 3 | import Enzyme, { shallow } from 'enzyme'; 4 | import Adapter from 'enzyme-adapter-react-16'; 5 | import { Container, injectable } from 'inversify'; 6 | import React, { Component } from 'react'; 7 | import ReactDOM from 'react-dom'; 8 | import { flushPromises, initContainer, sampleIdent, SampleService, SecondaryService } from '../testing/helpers'; 9 | import { createInjection } from './create-injection'; 10 | import { StateTracker } from './state-tracker'; 11 | 12 | // Configure enzyme. 13 | Enzyme.configure({ adapter: new Adapter() }); 14 | 15 | interface SamplePropsInjected { 16 | sampleService: SampleService; 17 | } 18 | 19 | interface SampleProps { 20 | normalProp: string; 21 | } 22 | 23 | interface SecondaryProps { 24 | secondaryService: SecondaryService; 25 | } 26 | 27 | const injectConfig = { 28 | sampleService: sampleIdent, 29 | }; 30 | 31 | let container: Container; 32 | let injection: ReturnType; 33 | let sampleProps: SampleProps & SamplePropsInjected; 34 | let secondaryInstance: SecondaryService; 35 | 36 | class SampleComponent extends Component { 37 | public state = { test: 'testing' }; 38 | 39 | public constructor(props: SampleProps & SamplePropsInjected) { 40 | super(props); 41 | sampleProps = props; 42 | } 43 | 44 | public render() { 45 | return ( 46 | <> 47 |
test render
48 |

{this.props.normalProp}

49 |

{this.props.sampleService.sample}

50 | 51 | ); 52 | } 53 | } 54 | 55 | class SecondaryComponent extends Component { 56 | public constructor(props: SecondaryProps) { 57 | super(props); 58 | secondaryInstance = props.secondaryService; 59 | } 60 | 61 | public render() { 62 | return

{this.props.secondaryService.test}

; 63 | } 64 | } 65 | 66 | class StateMappedComponent extends Component<{ testValue: string }> { 67 | public constructor(props: { testValue: string }) { 68 | super(props); 69 | } 70 | 71 | public render() { 72 | return

{this.props.testValue}

; 73 | } 74 | } 75 | 76 | function init() { 77 | return { 78 | div: document.createElement('div'), 79 | IP: injection.InjectionProvider, 80 | InjectedComponent: injection.injectComponent(injectConfig)(SampleComponent), 81 | }; 82 | } 83 | 84 | function renderHtml(shallowRend: Enzyme.ShallowWrapper, React.Component<{}, {}, any>>) { 85 | return new DOMParser().parseFromString(shallowRend.html(), 'text/html'); 86 | } 87 | 88 | beforeEach(() => { 89 | container = new Container(); 90 | container.bind(StateTracker).toSelf().inSingletonScope(); 91 | container.bind(sampleIdent).to(SampleService).inSingletonScope(); 92 | 93 | injection = createInjection(); 94 | }); 95 | 96 | it('initialises', () => { 97 | const { 98 | div, 99 | IP, 100 | InjectedComponent, 101 | } = init(); 102 | 103 | ReactDOM.render( 104 | 105 | 106 | , 107 | div, 108 | ); 109 | ReactDOM.unmountComponentAtNode(div); 110 | }); 111 | 112 | describe('InjectionProvider', () => { 113 | it('bind the StateTracker in the right scope if it was not already bound', () => { 114 | const { 115 | IP, 116 | } = init(); 117 | 118 | const cnt = new Container(); 119 | 120 | shallow(empty); 121 | 122 | expect(cnt.isBound(StateTracker)).toBe(true); 123 | 124 | const first = cnt.get(StateTracker); 125 | const second = cnt.get(StateTracker); 126 | expect(first).toBe(second); 127 | expect(first).toBeInstanceOf(StateTracker); 128 | }); 129 | 130 | it('does not bind the StateTracker if is was already', () => { 131 | const { 132 | IP, 133 | } = init(); 134 | 135 | const cnt = new Container(); 136 | container.bind(StateTracker).toSelf().inSingletonScope(); 137 | 138 | shallow(empty); 139 | 140 | expect(cnt.getAll(StateTracker)).toHaveLength(1); 141 | }); 142 | }); 143 | 144 | describe('injectComponent', () => { 145 | it('support passing the container by props', () => { 146 | const { 147 | div, 148 | InjectedComponent, 149 | } = init(); 150 | 151 | ReactDOM.render( 152 | , 153 | div, 154 | ); 155 | ReactDOM.unmountComponentAtNode(div); 156 | }); 157 | 158 | it('passes an instance of the injected service to the injected component', () => { 159 | const { 160 | InjectedComponent, 161 | } = init(); 162 | 163 | const rend = shallow(); 164 | const doc = renderHtml(rend); 165 | 166 | expect(sampleProps.sampleService).toBeInstanceOf(SampleService); 167 | expect(doc.querySelector('.sample')!.textContent).toBe('value1'); 168 | }); 169 | 170 | it('updates the component when the service state changes', async () => { 171 | const { 172 | div, 173 | InjectedComponent, 174 | } = init(); 175 | 176 | ReactDOM.render( 177 | , 178 | div, 179 | ); 180 | 181 | expect(sampleProps.sampleService).toBeInstanceOf(SampleService); 182 | 183 | sampleProps.sampleService.setSample('new value'); 184 | await flushPromises(); 185 | 186 | expect(div.querySelector('.sample')!.textContent).toBe('new value'); 187 | 188 | ReactDOM.unmountComponentAtNode(div); 189 | }); 190 | 191 | it('updates the component when any service state changes', async () => { 192 | const { 193 | div, 194 | } = init(); 195 | const InjectedComponent = injection.injectComponent({ 196 | secondaryService: SecondaryService, 197 | })(SecondaryComponent); 198 | 199 | const cnt = initContainer(); 200 | 201 | ReactDOM.render( 202 | , 203 | div, 204 | ); 205 | 206 | expect(div.querySelector('.sample')!.textContent).toBe('value1'); 207 | 208 | secondaryInstance.setTest('test value'); 209 | await flushPromises(); 210 | 211 | expect(div.querySelector('.sample')!.textContent).toBe('test value'); 212 | 213 | ReactDOM.unmountComponentAtNode(div); 214 | }); 215 | 216 | it('supports injection of services without binding StateTracker', () => { 217 | const { 218 | div, 219 | } = init(); 220 | 221 | @injectable() 222 | // @ts-ignore 223 | class TestService { 224 | public testMethod() { 225 | return 'test'; 226 | } 227 | } 228 | 229 | let output: string | undefined; 230 | let srv: TestService | undefined; 231 | const TestComp = injection.injectComponent({ 232 | test: TestService, 233 | })(({ test }: { test: TestService }) => { 234 | output = test.testMethod(); 235 | srv = test; 236 | 237 | return

test

; 238 | }); 239 | 240 | const cnt = new Container(); 241 | cnt.bind(TestService).toSelf(); 242 | 243 | ReactDOM.render( 244 | , 245 | div, 246 | ); 247 | 248 | expect(srv).toBeInstanceOf(TestService); 249 | expect(output).toBe('test'); 250 | 251 | ReactDOM.unmountComponentAtNode(div); 252 | }); 253 | 254 | it('handles state mapping properly', async () => { 255 | const { 256 | div, 257 | } = init(); 258 | const InjectedComponent = injection.injectComponent( 259 | { 260 | secondaryService: SecondaryService, 261 | }, 262 | services => ({ testValue: services.secondaryService.test }), 263 | )(StateMappedComponent); 264 | 265 | const cnt = initContainer(); 266 | 267 | ReactDOM.render( 268 | , 269 | div, 270 | ); 271 | 272 | expect(div.querySelector('.sample')!.textContent).toBe('value1'); 273 | 274 | ReactDOM.unmountComponentAtNode(div); 275 | }); 276 | }); 277 | --------------------------------------------------------------------------------