├── .nvmrc ├── .npmrc ├── .gitignore ├── jest.config.js ├── .size-limit.js ├── src ├── env.ts ├── config.ts ├── index.ts ├── exports.tsx ├── hoc.tsx ├── types.ts ├── renderProp.tsx ├── hook.ts └── medium.ts ├── .size.json ├── .travis.yml ├── tsconfig.json ├── LICENSE ├── CHANGELOG.md ├── __tests__ ├── renderProp.tsx ├── medium.ts └── sizecar.tsx ├── .eslintrc.js ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .DS_Store -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | }; 4 | -------------------------------------------------------------------------------- /.size-limit.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | path: 'dist/es2015/index.js', 4 | limit: '1.92kb', 5 | }, 6 | ]; 7 | -------------------------------------------------------------------------------- /src/env.ts: -------------------------------------------------------------------------------- 1 | import { isNode } from 'detect-node-es'; 2 | 3 | export const env = { 4 | isNode, 5 | forceCache: false, 6 | }; 7 | -------------------------------------------------------------------------------- /.size.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "dist/es2015/index.js", 4 | "passed": true, 5 | "size": 1914, 6 | "sizeLimit": 1920 7 | } 8 | ] 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '8' 4 | cache: yarn 5 | script: 6 | - yarn 7 | - yarn test:ci 8 | notifications: 9 | email: false -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | export interface IConfig { 2 | onError(e: Error): void; 3 | } 4 | 5 | export const config: IConfig = { 6 | onError: e => console.error(e), 7 | }; 8 | 9 | export const setConfig = (conf: Partial) => { 10 | Object.assign(config, conf); 11 | }; -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { sidecar } from './hoc'; 2 | export { useSidecar } from './hook'; 3 | export { setConfig } from './config'; 4 | export { createMedium, createSidecarMedium } from './medium'; 5 | export { renderCar } from './renderProp'; 6 | export { exportSidecar } from './exports'; 7 | 8 | export type { SideCarComponent } from './types'; 9 | -------------------------------------------------------------------------------- /src/exports.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { SideCarComponent, SideCarMedium } from './types'; 4 | 5 | const SideCar = ({ sideCar, ...rest }: any) => { 6 | if (!sideCar) { 7 | throw new Error('Sidecar: please provide `sideCar` property to import the right car'); 8 | } 9 | 10 | const Target = sideCar.read(); 11 | 12 | if (!Target) { 13 | throw new Error('Sidecar medium not found'); 14 | } 15 | 16 | return ; 17 | }; 18 | 19 | SideCar.isSideCarExport = true; 20 | 21 | export function exportSidecar(medium: SideCarMedium, exported: React.ComponentType): SideCarComponent { 22 | medium.useMedium(exported); 23 | 24 | return SideCar as any; 25 | } 26 | -------------------------------------------------------------------------------- /src/hoc.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { useSidecar } from './hook'; 4 | import { Importer, SideCarHOC } from './types'; 5 | 6 | // eslint-disable-next-line @typescript-eslint/ban-types 7 | export function sidecar( 8 | importer: Importer, 9 | errorComponent?: React.ReactNode 10 | ): React.FunctionComponent & SideCarHOC>> { 11 | const ErrorCase: React.FunctionComponent = () => errorComponent as any; 12 | 13 | return function Sidecar(props) { 14 | const [Car, error] = useSidecar(importer, props.sideCar); 15 | 16 | if (error && errorComponent) { 17 | return ErrorCase as any; 18 | } 19 | 20 | // @ts-expect-error type shenanigans 21 | return Car ? : null; 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "allowSyntheticDefaultImports": true, 5 | "strict": true, 6 | "strictNullChecks": true, 7 | "strictFunctionTypes": true, 8 | "noImplicitThis": true, 9 | "alwaysStrict": true, 10 | "noUnusedLocals": true, 11 | "noUnusedParameters": true, 12 | "noImplicitReturns": true, 13 | "noFallthroughCasesInSwitch": true, 14 | "noImplicitAny": true, 15 | "importHelpers": true, 16 | "isolatedModules": true, 17 | "target": "es6", 18 | "moduleResolution": "node", 19 | "lib": ["dom", "es5", "scripthost", "es2015.collection", "es2015.symbol", "es2015.iterable", "es2015.promise"], 20 | "types": ["node", "jest"], 21 | "typeRoots": ["./node_modules/@types"], 22 | "jsx": "react" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Anton Korzunov 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 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.1.2](https://github.com/theKashey/use-sidecar/compare/v1.1.1...v1.1.2) (2022-04-18) 2 | 3 | ### Bug Fixes 4 | 5 | - update Sidecar typescript integration ([8057bc5](https://github.com/theKashey/use-sidecar/commit/8057bc5cd863797d5c29e6e1955f33180f2f9aaa)) 6 | 7 | ## [1.1.1](https://github.com/theKashey/use-sidecar/compare/v1.1.0...v1.1.1) (2022-04-18) 8 | 9 | ### Bug Fixes 10 | 11 | - correct sidecar factory typing ([a7166cb](https://github.com/theKashey/use-sidecar/commit/a7166cbc3fed69d07d08a1107dd8c9467c83a924)) 12 | 13 | # [1.1.0](https://github.com/theKashey/use-sidecar/compare/v1.0.5...v1.1.0) (2022-04-17) 14 | 15 | ## [1.0.5](https://github.com/theKashey/use-sidecar/compare/v1.0.4...v1.0.5) (2021-03-18) 16 | 17 | ## [1.0.4](https://github.com/theKashey/use-sidecar/compare/v1.0.3...v1.0.4) (2021-01-17) 18 | 19 | ## [1.0.3](https://github.com/theKashey/use-sidecar/compare/v1.0.2...v1.0.3) (2020-07-28) 20 | 21 | ## [1.0.2](https://github.com/theKashey/use-sidecar/compare/v1.0.1...v1.0.2) (2019-10-25) 22 | 23 | ### Bug Fixes 24 | 25 | - handle detect-node in an esm compatible way ([797844c](https://github.com/theKashey/use-sidecar/commit/797844c75dab2219e9c491986c1f48c301a9d81a)) 26 | - provide an error if possible ([5a0a02c](https://github.com/theKashey/use-sidecar/commit/5a0a02c8b3ba804ef967a9844ddc8d450253ad44)) 27 | 28 | ## [1.0.1](https://github.com/theKashey/use-sidecar/compare/v1.0.0...v1.0.1) (2019-06-28) 29 | 30 | # [1.0.0](https://github.com/theKashey/use-sidecar/compare/v0.1.1...v1.0.0) (2019-06-25) 31 | 32 | ## [0.1.1](https://github.com/theKashey/use-sidecar/compare/v0.1.0...v0.1.1) (2019-06-07) 33 | 34 | # [0.1.0](https://github.com/theKashey/use-sidecar/compare/v0.0.2...v0.1.0) (2019-06-06) 35 | 36 | ## 0.0.2 (2019-05-28) 37 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export type removeCb = () => void; 4 | export type MediumCallback = (data: T) => any; 5 | export type MiddlewareCallback = (data: T, assigned: boolean) => T; 6 | export type SidePush = { 7 | length?: number; 8 | 9 | push(data: T): void; 10 | filter(cb: (x: T) => boolean): SidePush; 11 | }; 12 | 13 | /** 14 | * An object describing side medium 15 | */ 16 | export interface SideMedium { 17 | /** 18 | * Pushes effect to the medium 19 | * @param effect any information for real handler 20 | */ 21 | useMedium(effect: T): removeCb; 22 | 23 | /** 24 | * Assigns effect handler to the medium 25 | * @param {Function(effect: T)} handler effect handler 26 | */ 27 | assignMedium(handler: MediumCallback): void; 28 | 29 | /** 30 | * Assigns a synchronous effect handler to the medium, which would be executed right on call 31 | * @param {Function(effect: T)} handler effect handler 32 | */ 33 | assignSyncMedium(handler: MediumCallback): void; 34 | 35 | /** 36 | * reads the data stored in the medium 37 | */ 38 | read(): T | undefined; 39 | 40 | options?: Record; 41 | } 42 | 43 | export type DefaultOrNot = { default: T } | T; 44 | 45 | export type Importer = () => Promise>>; 46 | 47 | // eslint-disable-next-line @typescript-eslint/ban-types 48 | export type SideCarMedium = SideMedium>; 49 | 50 | // eslint-disable-next-line @typescript-eslint/ban-types 51 | export declare type SideCarHOC = { 52 | readonly sideCar: SideCarMedium; 53 | }; 54 | export declare type SideCarComponent = React.FunctionComponent>; 55 | 56 | export type SideCarMediumOptions = { 57 | async?: boolean; 58 | ssr?: boolean; 59 | }; 60 | -------------------------------------------------------------------------------- /src/renderProp.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useState, useCallback, useEffect, useLayoutEffect, FC } from 'react'; 3 | 4 | import { SideCarHOC } from './types'; 5 | 6 | type CombinedProps = { children: (...prop: T) => any } & K; 7 | type RenderPropComponent = React.ComponentType>; 8 | 9 | type Callback = (state: any) => void; 10 | 11 | type ChildrenProps = { 12 | stateRef: React.MutableRefObject; 13 | defaultState: React.RefObject; 14 | children: (...prop: T) => any; 15 | }; 16 | 17 | export function renderCar>>( 18 | WrappedComponent: C, 19 | defaults: (props: K) => T 20 | ): FC> { 21 | function State({ stateRef, props }: { stateRef: React.RefObject; props: CombinedProps }) { 22 | const renderTarget = useCallback(function SideTarget(...args: T) { 23 | useLayoutEffect(() => { 24 | stateRef.current!(args); 25 | }); 26 | 27 | return null; 28 | }, []); 29 | 30 | // @ts-ignore 31 | return ; 32 | } 33 | 34 | const Children = React.memo( 35 | ({ stateRef, defaultState, children }: ChildrenProps) => { 36 | const [state, setState] = useState(defaultState.current!); 37 | 38 | useEffect(() => { 39 | stateRef.current = setState; 40 | }, []); 41 | 42 | return children(...state); 43 | }, 44 | () => true 45 | ); 46 | 47 | return function Combiner(props: CombinedProps) { 48 | const defaultState = React.useRef(defaults(props)); 49 | const ref = React.useRef((state: T) => (defaultState.current = state)); 50 | 51 | return ( 52 | 53 | 54 | 55 | 56 | ); 57 | }; 58 | } 59 | -------------------------------------------------------------------------------- /src/hook.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | import { env } from './env'; 4 | import { Importer, SideMedium } from './types'; 5 | 6 | const cache = new WeakMap(); 7 | 8 | const NO_OPTIONS = {}; 9 | 10 | export function useSidecar( 11 | importer: Importer, 12 | effect?: SideMedium 13 | ): [React.ComponentType | null, Error | null] { 14 | const options: any = (effect && effect.options) || NO_OPTIONS; 15 | 16 | if (env.isNode && !options.ssr) { 17 | return [null, null]; 18 | } 19 | 20 | // eslint-disable-next-line react-hooks/rules-of-hooks 21 | return useRealSidecar(importer, effect); 22 | } 23 | 24 | function useRealSidecar( 25 | importer: Importer, 26 | effect?: SideMedium 27 | ): [React.ComponentType | null, Error | null] { 28 | const options: any = (effect && effect.options) || NO_OPTIONS; 29 | 30 | const couldUseCache = env.forceCache || (env.isNode && !!options.ssr) || !options.async; 31 | 32 | const [Car, setCar] = useState(couldUseCache ? () => cache.get(importer) : undefined); 33 | const [error, setError] = useState(null); 34 | 35 | useEffect(() => { 36 | if (!Car) { 37 | importer().then( 38 | (car) => { 39 | const resolved: T = effect ? effect.read() : (car as any).default || car; 40 | 41 | if (!resolved) { 42 | console.error('Sidecar error: with importer', importer); 43 | 44 | let error: Error; 45 | 46 | if (effect) { 47 | console.error('Sidecar error: with medium', effect); 48 | error = new Error('Sidecar medium was not found'); 49 | } else { 50 | error = new Error('Sidecar was not found in exports'); 51 | } 52 | 53 | setError(() => error); 54 | throw error; 55 | } 56 | 57 | cache.set(importer, resolved); 58 | setCar(() => resolved); 59 | }, 60 | (e) => setError(() => e) 61 | ); 62 | } 63 | }, []); 64 | 65 | return [Car, error]; 66 | } 67 | -------------------------------------------------------------------------------- /__tests__/renderProp.tsx: -------------------------------------------------------------------------------- 1 | import { configure, mount } from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | import * as React from 'react'; 4 | 5 | import { FC } from 'react'; 6 | 7 | import { renderCar, sidecar } from '../src'; 8 | import { env } from '../src/env'; 9 | 10 | configure({ adapter: new Adapter() }); 11 | 12 | const tick = () => new Promise((resolve) => setTimeout(resolve, 10)); 13 | 14 | describe('RenderProp', () => { 15 | it('smoke', async () => { 16 | let resolveExternal: (x: any) => void; 17 | 18 | interface BaseProps { 19 | y: number; 20 | } 21 | 22 | interface Props { 23 | x: number; 24 | 25 | children(props: BaseProps): React.ReactNode; 26 | } 27 | 28 | const external = new Promise>((resolve) => { 29 | resolveExternal = resolve; 30 | }); 31 | 32 | env.isNode = false; 33 | env.forceCache = true; 34 | 35 | const Car = sidecar(() => external); 36 | const CarRender = renderCar(Car, (props: Props = {} as any) => [{ y: props.x + 1 }]); 37 | 38 | const fn = jest.fn(); 39 | 40 | const App: FC<{ x: number }> = ({ x }) => ( 41 |
42 | 43 | {(props) => { 44 | fn(props); 45 | 46 | return props.y; 47 | }} 48 | 49 |
50 | ); 51 | 52 | const wrapper = mount(); 53 | 54 | expect(wrapper.html()).toBe('
2
'); 55 | expect(fn).toHaveBeenCalledWith({ y: 2 }); 56 | expect(fn).toHaveBeenCalledTimes(1); 57 | 58 | resolveExternal!((props: Props) => { 59 | return props.children({ y: (props.x + 2) * 2 }); 60 | }); 61 | 62 | await tick(); 63 | 64 | wrapper.update(); 65 | 66 | expect(wrapper.html()).toBe('
6
'); 67 | expect(fn).toHaveBeenCalledWith({ y: 6 }); 68 | expect(fn).toHaveBeenCalledTimes(2); 69 | 70 | wrapper.setProps({ x: 2 }); 71 | 72 | expect(wrapper.html()).toBe('
8
'); 73 | expect(fn).toHaveBeenCalledTimes(3); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['plugin:@typescript-eslint/recommended', 'plugin:import/typescript', 'plugin:react-hooks/recommended'], 3 | parser: '@typescript-eslint/parser', 4 | plugins: ['@typescript-eslint', 'prettier', 'import'], 5 | rules: { 6 | '@typescript-eslint/ban-ts-comment': 0, 7 | '@typescript-eslint/ban-ts-ignore': 0, 8 | '@typescript-eslint/no-var-requires': 0, 9 | '@typescript-eslint/camelcase': 0, 10 | 'import/order': [ 11 | 'error', 12 | { 13 | 'newlines-between': 'always-and-inside-groups', 14 | alphabetize: { 15 | order: 'asc', 16 | }, 17 | groups: ['builtin', 'external', 'internal', ['parent', 'index', 'sibling']], 18 | }, 19 | ], 20 | 'padding-line-between-statements': [ 21 | 'error', 22 | // IMPORT 23 | { 24 | blankLine: 'always', 25 | prev: 'import', 26 | next: '*', 27 | }, 28 | { 29 | blankLine: 'any', 30 | prev: 'import', 31 | next: 'import', 32 | }, 33 | // EXPORT 34 | { 35 | blankLine: 'always', 36 | prev: '*', 37 | next: 'export', 38 | }, 39 | { 40 | blankLine: 'any', 41 | prev: 'export', 42 | next: 'export', 43 | }, 44 | { 45 | blankLine: 'always', 46 | prev: '*', 47 | next: ['const', 'let'], 48 | }, 49 | { 50 | blankLine: 'any', 51 | prev: ['const', 'let'], 52 | next: ['const', 'let'], 53 | }, 54 | // BLOCKS 55 | { 56 | blankLine: 'always', 57 | prev: ['block', 'block-like', 'class', 'function', 'multiline-expression'], 58 | next: '*', 59 | }, 60 | { 61 | blankLine: 'always', 62 | prev: '*', 63 | next: ['block', 'block-like', 'class', 'function', 'return', 'multiline-expression'], 64 | }, 65 | ], 66 | }, 67 | settings: { 68 | 'import/parsers': { 69 | '@typescript-eslint/parser': ['.ts', '.tsx'], 70 | }, 71 | 'import/resolver': { 72 | typescript: { 73 | alwaysTryTypes: true, 74 | }, 75 | }, 76 | }, 77 | }; 78 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-sidecar", 3 | "version": "1.1.3", 4 | "description": "Sidecar code splitting utils", 5 | "module:es2019": "dist/es2019/index.js", 6 | "main": "dist/es5/index.js", 7 | "module": "dist/es2015/index.js", 8 | "types": "dist/es5/index.d.ts", 9 | "devDependencies": { 10 | "@size-limit/preset-small-lib": "^11.0.2", 11 | "size-limit": "^11.0.2", 12 | "@theuiteam/lib-builder": "^0.1.4", 13 | "@types/enzyme-adapter-react-16": "^1.0.6", 14 | "enzyme-adapter-react-16": "^1.15.6", 15 | "react": "^16.8.6", 16 | "react-dom": "^16.8.6" 17 | }, 18 | "engines": { 19 | "node": ">=10" 20 | }, 21 | "scripts": { 22 | "dev": "lib-builder dev", 23 | "test": "jest", 24 | "test:ci": "jest --runInBand --coverage", 25 | "build": "lib-builder build && yarn size:report", 26 | "release": "yarn build && yarn test", 27 | "size": "npx size-limit", 28 | "size:report": "npx size-limit --json > .size.json", 29 | "lint": "lib-builder lint", 30 | "format": "lib-builder format", 31 | "update": "lib-builder update", 32 | "prepublish": "yarn build && yarn changelog", 33 | "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s", 34 | "changelog:rewrite": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0" 35 | }, 36 | "peerDependencies": { 37 | "@types/react": "*", 38 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" 39 | }, 40 | "sideEffects": [ 41 | "**/medium.js" 42 | ], 43 | "files": [ 44 | "dist" 45 | ], 46 | "keywords": [ 47 | "code spliting", 48 | "react", 49 | "sidecar" 50 | ], 51 | "homepage": "https://github.com/theKashey/use-sidecar", 52 | "author": "theKashey ", 53 | "license": "MIT", 54 | "dependencies": { 55 | "detect-node-es": "^1.1.0", 56 | "tslib": "^2.0.0" 57 | }, 58 | "peerDependenciesMeta": { 59 | "@types/react": { 60 | "optional": true 61 | } 62 | }, 63 | "repository": { 64 | "type": "git", 65 | "url": "https://github.com/theKashey/use-sidecar" 66 | }, 67 | "husky": { 68 | "hooks": { 69 | "pre-commit": "lint-staged" 70 | } 71 | }, 72 | "lint-staged": { 73 | "*.{ts,tsx}": [ 74 | "prettier --write", 75 | "eslint --fix", 76 | "git add" 77 | ], 78 | "*.{js,css,json,md}": [ 79 | "prettier --write", 80 | "git add" 81 | ] 82 | }, 83 | "resolutions": { 84 | "@types/react": "^19.0.0" 85 | }, 86 | "prettier": { 87 | "printWidth": 120, 88 | "trailingComma": "es5", 89 | "tabWidth": 2, 90 | "semi": true, 91 | "singleQuote": true 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/medium.ts: -------------------------------------------------------------------------------- 1 | import { MediumCallback, MiddlewareCallback, SideCarMedium, SideCarMediumOptions, SideMedium, SidePush } from './types'; 2 | 3 | function ItoI(a: T) { 4 | return a; 5 | } 6 | 7 | function innerCreateMedium(defaults?: T, middleware: MiddlewareCallback = ItoI): SideMedium { 8 | let buffer: SidePush = []; 9 | let assigned = false; 10 | 11 | const medium: SideMedium = { 12 | read(): T { 13 | if (assigned) { 14 | throw new Error( 15 | 'Sidecar: could not `read` from an `assigned` medium. `read` could be used only with `useMedium`.' 16 | ); 17 | } 18 | 19 | if (buffer.length) { 20 | return (buffer as Array)[buffer.length - 1]; 21 | } 22 | 23 | return defaults!; 24 | }, 25 | useMedium(data: T) { 26 | const item = middleware(data, assigned); 27 | buffer.push(item); 28 | 29 | return () => { 30 | buffer = buffer.filter((x) => x !== item); 31 | }; 32 | }, 33 | assignSyncMedium(cb: MediumCallback) { 34 | assigned = true; 35 | 36 | while (buffer.length) { 37 | const cbs = buffer as Array; 38 | buffer = []; 39 | cbs.forEach(cb); 40 | } 41 | 42 | buffer = { 43 | push: (x) => cb(x), 44 | filter: () => buffer, 45 | }; 46 | }, 47 | assignMedium(cb: MediumCallback) { 48 | assigned = true; 49 | 50 | let pendingQueue: Array = []; 51 | 52 | if (buffer.length) { 53 | const cbs = buffer as Array; 54 | buffer = []; 55 | cbs.forEach(cb); 56 | pendingQueue = buffer as Array; 57 | } 58 | 59 | const executeQueue = () => { 60 | const cbs = pendingQueue; 61 | pendingQueue = []; 62 | cbs.forEach(cb); 63 | }; 64 | 65 | const cycle = () => Promise.resolve().then(executeQueue); 66 | 67 | cycle(); 68 | 69 | buffer = { 70 | push: (x) => { 71 | pendingQueue.push(x); 72 | cycle(); 73 | }, 74 | filter: (filter) => { 75 | pendingQueue = pendingQueue.filter(filter); 76 | 77 | return buffer; 78 | }, 79 | }; 80 | }, 81 | }; 82 | 83 | return medium; 84 | } 85 | 86 | export function createMedium(defaults?: T, middleware: MiddlewareCallback = ItoI): Readonly> { 87 | return innerCreateMedium(defaults, middleware); 88 | } 89 | 90 | // eslint-disable-next-line @typescript-eslint/ban-types 91 | export function createSidecarMedium(options: SideCarMediumOptions = {}): Readonly> { 92 | const medium = innerCreateMedium(null as any); 93 | 94 | medium.options = { 95 | async: true, 96 | ssr: false, 97 | ...options, 98 | }; 99 | 100 | return medium; 101 | } 102 | -------------------------------------------------------------------------------- /__tests__/medium.ts: -------------------------------------------------------------------------------- 1 | import { createMedium } from '../src'; 2 | 3 | describe('medium', () => { 4 | const tick = () => new Promise((resolve) => setTimeout(resolve, 10)); 5 | 6 | it('set/read', () => { 7 | const medium = createMedium(42); 8 | expect(medium.read()).toBe(42); 9 | medium.useMedium(24); 10 | expect(medium.read()).toBe(24); 11 | medium.useMedium(100); 12 | expect(medium.read()).toBe(100); 13 | }); 14 | 15 | it('set/use - async', async () => { 16 | const medium = createMedium(); 17 | medium.useMedium(42); 18 | medium.useMedium(24); 19 | 20 | const spy = jest.fn(); 21 | const result: number[] = []; 22 | 23 | medium.assignMedium((arg) => { 24 | spy(arg); 25 | result.push(arg); 26 | 27 | if (arg === 42) { 28 | medium.useMedium(100); 29 | } 30 | }); 31 | 32 | expect(spy).toHaveBeenCalledWith(42); 33 | expect(spy).toHaveBeenCalledWith(24); 34 | 35 | expect(result).toEqual([42, 24]); 36 | 37 | await tick(); 38 | 39 | expect(result).toEqual([42, 24, 100]); 40 | }); 41 | 42 | it('set/use - sync', () => { 43 | const medium = createMedium(); 44 | medium.useMedium(42); 45 | medium.useMedium(24); 46 | 47 | const spy = jest.fn(); 48 | const result: number[] = []; 49 | 50 | medium.assignSyncMedium((arg) => { 51 | spy(arg); 52 | result.push(arg); 53 | 54 | if (arg === 42) { 55 | medium.useMedium(100); 56 | } 57 | }); 58 | 59 | expect(spy).toHaveBeenCalledWith(42); 60 | expect(spy).toHaveBeenCalledWith(24); 61 | 62 | expect(result).toEqual([42, 24, 100]); 63 | }); 64 | 65 | it('Push new values', async () => { 66 | const medium = createMedium(); 67 | medium.useMedium(42); 68 | 69 | const spy = jest.fn(); 70 | const result: number[] = []; 71 | 72 | medium.assignMedium((arg) => { 73 | spy(arg); 74 | result.push(arg); 75 | }); 76 | 77 | medium.useMedium(24); 78 | 79 | expect(spy).toHaveBeenCalledWith(42); 80 | expect(spy).not.toHaveBeenCalledWith(24); 81 | expect(result).toEqual([42]); 82 | 83 | await tick(); 84 | 85 | expect(spy).toHaveBeenCalledWith(24); 86 | 87 | expect(result).toEqual([42, 24]); 88 | }); 89 | 90 | it('Push new values sync', async () => { 91 | const medium = createMedium(); 92 | medium.useMedium(42); 93 | 94 | const spy = jest.fn(); 95 | const result: number[] = []; 96 | 97 | medium.assignSyncMedium((arg) => { 98 | spy(arg); 99 | result.push(arg); 100 | }); 101 | 102 | medium.useMedium(24); 103 | 104 | expect(spy).toHaveBeenCalledWith(42); 105 | expect(spy).toHaveBeenCalledWith(24); 106 | expect(result).toEqual([42, 24]); 107 | 108 | await tick(); 109 | 110 | expect(result).toEqual([42, 24]); 111 | }); 112 | 113 | it('ts test for import', () => { 114 | const utilMedium = createMedium<(cb: typeof import('../src/hoc')) => void>(); 115 | 116 | utilMedium.useMedium((test) => { 117 | expect(test.sidecar(42 as any)).toBe(42); 118 | }); 119 | 120 | const spy = jest.fn().mockImplementation((a) => a); 121 | utilMedium.assignMedium((cb) => cb({ sidecar: spy })); 122 | 123 | expect(spy).toHaveBeenCalledWith(42); 124 | }); 125 | }); 126 | -------------------------------------------------------------------------------- /__tests__/sizecar.tsx: -------------------------------------------------------------------------------- 1 | import { configure, mount } from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | import * as React from 'react'; 4 | 5 | import { sidecar, exportSidecar, createSidecarMedium } from '../src'; 6 | import { env } from '../src/env'; 7 | 8 | configure({ adapter: new Adapter() }); 9 | 10 | const tick = () => new Promise((resolve) => setTimeout(resolve, 10)); 11 | 12 | describe('sidecar', () => { 13 | const noCar = null as any; 14 | 15 | describe('ServerSide', function () { 16 | beforeEach(() => { 17 | env.isNode = true; 18 | }); 19 | 20 | it('should never import car', async () => { 21 | const importer = () => Promise.resolve(() =>
test
); 22 | 23 | const SC = sidecar(importer); 24 | 25 | const wrapper = mount(); 26 | expect(wrapper.html()).toBe(null); 27 | 28 | await tick(); 29 | 30 | expect(wrapper.update().html()).toBe(null); 31 | 32 | // remount 33 | 34 | const remounted = mount(); 35 | expect(remounted.html()).toBe(null); 36 | }); 37 | 38 | it('should load ssr car', async () => { 39 | const sc = createSidecarMedium({ ssr: true }); 40 | const Comp = exportSidecar(sc, () =>
test
); 41 | const importer = () => Promise.resolve(Comp); 42 | 43 | const SC = sidecar(importer); 44 | 45 | const wrapper = mount(); 46 | expect(wrapper.html()).toBe(null); 47 | 48 | await tick(); 49 | 50 | expect(wrapper.update().html()).toBe('
test
'); 51 | 52 | // remount 53 | 54 | const remounted = mount(); 55 | expect(remounted.html()).toBe('
test
'); 56 | }); 57 | }); 58 | 59 | describe('ClientSide', function () { 60 | beforeEach(() => { 61 | env.isNode = false; 62 | }); 63 | 64 | it('should load import car', async () => { 65 | const importer = () => Promise.resolve(() =>
test
); 66 | 67 | const SC = sidecar(importer); 68 | 69 | const wrapper = mount(); 70 | expect(wrapper.html()).toBe(null); 71 | 72 | await tick(); 73 | 74 | expect(wrapper.update().html()).toBe('
test
'); 75 | 76 | // remount 77 | 78 | const remounted = mount(); 79 | expect(remounted.html()).toBe('
test
'); 80 | }); 81 | 82 | it('should error car', async () => { 83 | const sc = createSidecarMedium({ async: false }); 84 | const Comp = exportSidecar(sc, () =>
test
); 85 | 86 | expect(() => mount()).toThrow(); 87 | }); 88 | 89 | it('typed sidecar error car', async () => { 90 | const sc1 = createSidecarMedium({ async: false }); 91 | // @ts-expect-error 92 | exportSidecar(sc1, ({ x }: { x: string }) =>
{x}
); 93 | 94 | const sc2 = createSidecarMedium<{ x: string }>({ async: false }); 95 | exportSidecar(sc2, ({ x }: { x: string }) =>
{x}
); 96 | 97 | const sc3 = createSidecarMedium<{ x: string }>({ async: false }); 98 | exportSidecar(sc3, () =>
test
); 99 | 100 | expect(1).toBe(1); 101 | }); 102 | 103 | it('should load sync car', async () => { 104 | const sc = createSidecarMedium({ async: false }); 105 | const Comp = exportSidecar(sc, () =>
test
); 106 | const importer = () => Promise.resolve(Comp); 107 | 108 | const SC = sidecar(importer); 109 | 110 | const wrapper = mount(); 111 | expect(wrapper.html()).toBe(null); 112 | 113 | await tick(); 114 | 115 | expect(wrapper.update().html()).toBe('
test
'); 116 | 117 | // remount 118 | 119 | const remounted = mount(); 120 | expect(remounted.html()).toBe('
test
'); 121 | }); 122 | 123 | it('should load async car', async () => { 124 | const sc = createSidecarMedium<{ x: number }>(); 125 | const Comp = exportSidecar(sc, ({ x }) =>
test {x || 'undefined'}
); 126 | const importer = () => Promise.resolve(Comp); 127 | 128 | const SC = sidecar(importer); 129 | 130 | const wrapper = mount(); 131 | expect(wrapper.html()).toBe(null); 132 | 133 | await tick(); 134 | 135 | expect(wrapper.update().html()).toBe('
test 42
'); 136 | 137 | // remount 138 | 139 | // @ts-expect-error 140 | const remounted = mount(); 141 | expect(remounted.html()).toBe(null); 142 | await tick(); 143 | expect(remounted.update().html()).toBe('
test undefined
'); 144 | }); 145 | }); 146 | }); 147 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

🏎 side car

3 |
4 | Alternative way to code splitting 5 |
6 | 7 | 8 | 9 | 10 | 11 | 12 | Build status 13 | 14 | 15 | 16 | npm downloads 17 | 18 | 19 | 20 | bundle size 21 | 22 |
23 |
24 | 25 | UI/Effects code splitting pattern 26 | - [read the original article](https://dev.to/thekashey/sidecar-for-a-code-splitting-1o8g) to understand concepts behind. 27 | - [read how Google](https://medium.com/@cramforce/designing-very-large-javascript-applications-6e013a3291a3) split view and logic. 28 | - [watch how Facebook](https://developers.facebook.com/videos/2019/building-the-new-facebookcom-with-react-graphql-and-relay/) defers "interactivity" effects. 29 | 30 | ## Terminology: 31 | - `sidecar` - non UI component, which may carry effects for a paired UI component. 32 | - `UI` - UI component, which interactivity is moved to a `sidecar`. 33 | 34 | `UI` is a _view_, `sidecar` is the _logic_ for it. Like Batman(UI) and his sidekick Robin(effects). 35 | 36 | ## Concept 37 | - a `package` exposes __3 entry points__ using a [nested `package.json` format](https://github.com/theKashey/multiple-entry-points-example): 38 | - default aka `combination`, and lets hope tree shaking will save you 39 | - `UI`, with only UI part 40 | - `sidecar`, with all the logic 41 | - > `UI` + `sidecar` === `combination`. The size of `UI+sidecar` might a bit bigger than size of their `combination`. 42 | Use [size-limit](https://github.com/ai/size-limit) to control their size independently. 43 | 44 | 45 | - package uses a `medium` to talk with own sidecar, breaking explicit dependency. 46 | 47 | - if package depends on another _sidecar_ package: 48 | - it shall export dependency side car among own sidecar. 49 | - package imports own sidecar via `medium`, thus able to export multiple sidecars via one export. 50 | 51 | - final consumer uses `sidecar` or `useSidecar` to combine pieces together. 52 | 53 | ## Rules 54 | - `UI` components might use/import any other `UI` components 55 | - `sidecar` could use/import any other `sidecar` 56 | 57 | That would form two different code branches, you may load separately - UI first, and effect sidecar later. 58 | That also leads to a obvious consequence - __one sidecar may export all sidecars__. 59 | - to decouple `sidecars` from module exports, and be able to pick "the right" one at any point 60 | you have to use `exportSidecar(medium, component)` to export it, and use the same `medium` to import it back. 61 | - this limitation is for __libraries only__, as long as in the usercode you might 62 | dynamically import whatever and whenever you want. 63 | 64 | - `useMedium` is always async - action would be executed in a next tick, or on the logic load. 65 | - `sidecar` is always async - is does not matter have you loaded logic or not - component would be 66 | rendered at least in the next tick. 67 | 68 | > except `medium.read`, which synchronously read the data from a medium, 69 | and `medium.assingSyncMedium` which changes `useMedium` to be sync. 70 | 71 | ## SSR and usage tracking 72 | Sidecar pattern is clear: 73 | - you dont need to use/render any `sidecars` on server. 74 | - you dont have to load `sidecars` prior main render. 75 | 76 | Thus - no usage tracking, and literally no SSR. It's just skipped. 77 | 78 | 79 | # API 80 | 81 | ## createMedium() 82 | - Type: Util. Creates shared effect medium for algebraic effect. 83 | - Goal: To decouple modules from each other. 84 | - Usage: `use` in UI side, and `assign` from side-car. All effects would be executed. 85 | - Analog: WeakMap, React.__SECRET_DOM_DO_NOT_USE_OR_YOU_WILL_BE_FIRED 86 | ```js 87 | const medium = createMedium(defaultValue); 88 | const cancelCb = medium.useMedium(someData); 89 | 90 | // like 91 | useEffect(() => medium.useMedium(someData), []); 92 | 93 | medium.assignMedium(someDataProcessor) 94 | 95 | // createSidecarMedium is a helper for createMedium to create a "sidecar" symbol 96 | const effectCar = createSidecarMedium(); 97 | ``` 98 | 99 | > ! For consistence `useMedium` is async - sidecar load status should not affect function behavior, 100 | thus effect would be always executed at least in the "next tick". You may alter 101 | this behavior by using `medium.assingSyncMedium`. 102 | 103 | 104 | ## exportSidecar(medium, component) 105 | - Type: HOC 106 | - Goal: store `component` inside `medium` and return external wrapper 107 | - Solving: decoupling module exports to support exporting multiple sidecars via a single entry point. 108 | - Usage: use to export a `sidecar` 109 | - Analog: WeakMap 110 | ```js 111 | import {effectCar} from './medium'; 112 | import {EffectComponent} from './Effect'; 113 | // !!! - to prevent Effect from being imported 114 | // `effectCar` medium __have__ to be defined in another file 115 | // const effectCar = createSidecarMedium(); 116 | export default exportSidecar(effectCar, EffectComponent); 117 | ``` 118 | 119 | ## sidecar(importer) 120 | - Type: HOC 121 | - Goal: React.lazy analog for code splitting, but does not require `Suspense`, might provide error failback. 122 | - Usage: like React.lazy to load a side-car component. 123 | - Analog: React.Lazy 124 | ```js 125 | import {sidecar} from "use-sidecar"; 126 | const Sidecar = sidecar(() => import('./sidecar'), on fail); 127 | 128 | <> 129 | 130 | 131 | 132 | ``` 133 | ### Importing `exportedSidecar` 134 | Would require additional prop to be set - `````` 135 | 136 | ## useSidecar(importer) 137 | - Type: hook, loads a `sideCar` using provided `importer` which shall follow React.lazy API 138 | - Goal: to load a side car without displaying any "spinners". 139 | - Usage: load side car for a component 140 | - Analog: none 141 | ```js 142 | import {useSidecar} from 'use-sidecar'; 143 | 144 | const [Car, error] = useSidecar(() => import('./sideCar')); 145 | return ( 146 | <> 147 | {Car ? : null} 148 | 149 | 150 | ); 151 | ``` 152 | ### Importing `exportedSideCar` 153 | You have to specify __effect medium__ to read data from, as long as __export itself is empty__. 154 | ```js 155 | import {useSidecar} from 'use-sidecar'; 156 | 157 | /* medium.js: */ export const effectCar = useMedium({}); 158 | /* sideCar.js: */export default exportSidecar(effectCar, EffectComponent); 159 | 160 | const [Car, error] = useSidecar(() => import('./sideCar'), effectCar); 161 | return ( 162 | <> 163 | {Car ? : null} 164 | 165 | 166 | ); 167 | ``` 168 | 169 | ## renderCar(Component) 170 | - Type: HOC, moves renderProp component to a side channel 171 | - Goal: Provide render prop support, ie defer component loading keeping tree untouched. 172 | - Usage: Provide `defaults` and use them until sidecar is loaded letting you code split (non visual) render-prop component 173 | - Analog: - Analog: code split library like [react-imported-library](https://github.com/theKashey/react-imported-library) or [@loadable/lib](https://www.smooth-code.com/open-source/loadable-components/docs/library-splitting/). 174 | ```js 175 | import {renderCar, sidecar} from "use-sidecar"; 176 | const RenderCar = renderCar( 177 | // will move side car to a side channel 178 | sidecar(() => import('react-powerplug').then(imports => imports.Value)), 179 | // default render props 180 | [{value: 0}] 181 | ); 182 | 183 | 184 | {({value}) => {value}} 185 | 186 | ``` 187 | 188 | ## setConfig(config) 189 | ```js 190 | setConfig({ 191 | onError, // sets default error handler 192 | }); 193 | ``` 194 | 195 | # Examples 196 | ## Deferred effect 197 | Let's imagine - on element focus you have to do "something", for example focus anther element 198 | 199 | #### Original code 200 | ```js 201 | onFocus = event => { 202 | if (event.currentTarget === event.target) { 203 | document.querySelectorAll('button', event.currentTarget) 204 | } 205 | } 206 | ``` 207 | 208 | #### Sidecar code 209 | 210 | 3. Use medium (yes, .3) 211 | ```js 212 | // we are calling medium with an original event as an argument 213 | const onFocus = event => focusMedium.useMedium(event); 214 | ``` 215 | 2. Define reaction 216 | ```js 217 | // in a sidecar 218 | 219 | // we are setting handler for the effect medium 220 | // effect is complicated - we are skipping event "bubbling", 221 | // and focusing some button inside a parent 222 | focusMedium.assignMedium(event => { 223 | if (event.currentTarget === event.target) { 224 | document.querySelectorAll('button', event.currentTarget) 225 | } 226 | }); 227 | 228 | ``` 229 | 1. Create medium 230 | Having these constrains - we have to clone `event`, as long as React would eventually reuse SyntheticEvent, thus not 231 | preserve `target` and `currentTarget`. 232 | ```js 233 | // 234 | const focusMedium = createMedium(null, event => ({...event})); 235 | ``` 236 | Now medium side effect is ok to be async 237 | 238 | __Example__: [Effect for react-focus-lock](https://github.com/theKashey/react-focus-lock/blob/8c69c644ecfeed2ec9dc0dc4b5b30e896a366738/src/Lock.js#L48) - 1kb UI, 4kb sidecar 239 | 240 | ### Medium callback 241 | Like a library level code splitting 242 | 243 | #### Original code 244 | ```js 245 | import {x, y} from './utils'; 246 | 247 | useEffect(() => { 248 | if (x()) { 249 | y() 250 | } 251 | }, []); 252 | ``` 253 | 254 | #### Sidecar code 255 | 256 | ```js 257 | // medium 258 | const utilMedium = createMedium(); 259 | 260 | // utils 261 | const x = () => { /* ... */}; 262 | const y = () => { /* ... */}; 263 | 264 | // medium will callback with exports exposed 265 | utilMedium.assignMedium(cb => cb({ 266 | x, y 267 | })); 268 | 269 | 270 | // UI 271 | // not importing x and y from the module system, but would be given via callback 272 | useEffect(() => { 273 | utilMedium.useMedium(({x,y}) => { 274 | if (x()) { 275 | y() 276 | } 277 | }) 278 | }, []); 279 | ``` 280 | 281 | - Hint: there is a easy way to type it 282 | ```js 283 | const utilMedium = createMedium<(cb: typeof import('./utils')) => void>(); 284 | ``` 285 | 286 | __Example__: [Callback API for react-focus-lock](https://github.com/theKashey/react-focus-lock/blob/8c69c644ecfeed2ec9dc0dc4b5b30e896a366738/src/MoveFocusInside.js#L12) 287 | 288 | ### Split effects 289 | Lets take an example from a Google - Calendar app, with view and logic separated. 290 | To be honest - it's not easy to extract logic from application like calendar - usually it's tight coupled. 291 | 292 | #### Original code 293 | ```js 294 | const CalendarUI = () => { 295 | const [date, setDate] = useState(); 296 | const onButtonClick = useCallback(() => setDate(Date.now), []); 297 | 298 | return ( 299 | <> 300 | 301 | Set Today 302 | 303 | ) 304 | } 305 | ``` 306 | #### Sidecar code 307 | 308 | ```js 309 | const CalendarUI = () => { 310 | const [events, setEvents] = useState({}); 311 | const [date, setDate] = useState(); 312 | 313 | return ( 314 | <> 315 | 316 | 317 | 318 | ) 319 | } 320 | 321 | const UILayout = ({onDateChange, onButtonClick, date}) => ( 322 | <> 323 | 324 | Set Today 325 | 326 | ); 327 | 328 | // in a sidecar 329 | // we are providing callbacks back to UI 330 | const Sidecar = ({setDate, setEvents}) => { 331 | useEffect(() => setEvents({ 332 | onDateChange:setDate, 333 | onButtonClick: () => setDate(Date.now), 334 | }), []); 335 | 336 | return null; 337 | } 338 | ``` 339 | 340 | While in this example this looks a bit, you know, strange - there are 3 times more code 341 | that in the original example - that would make a sense for a real Calendar, especially 342 | if some helper library, like `moment`, has been used. 343 | 344 | __Example__: [Effect for react-remove-scroll](https://github.com/theKashey/react-remove-scroll/blob/666472d5c77fb6c4e5beffdde87c53ae63ef42c5/src/SideEffect.tsx#L166) - 300b UI, 2kb sidecar 345 | 346 | # Licence 347 | 348 | MIT 349 | 350 | --------------------------------------------------------------------------------