├── tsconfig.json ├── src ├── core │ ├── index.ts │ ├── create-reflect │ │ └── index.ts │ └── reflect │ │ └── index.ts ├── no-ssr │ ├── index.ts │ ├── create-reflect.test.tsx │ └── reflect.test.tsx └── ssr │ ├── index.ts │ ├── create-reflect.test.tsx │ └── reflect.test.tsx ├── .gitignore ├── .prettierrc ├── babel.config.json ├── .eslintrc ├── .config └── tsconfig.base.json ├── rollup.config.js ├── .github └── workflows │ └── change-version.yml ├── lib └── rollup-plugin-export-root │ └── index.js ├── package.json ├── dist-test ├── ssr │ ├── create-reflect.test.tsx │ └── reflect.test.tsx └── no-ssr │ ├── create-reflect.test.tsx │ └── reflect.test.tsx └── Readme.md /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.config/tsconfig.base.json" 3 | } 4 | -------------------------------------------------------------------------------- /src/core/index.ts: -------------------------------------------------------------------------------- 1 | export { reflectCreator } from './reflect'; 2 | export { createReflectCreator } from './create-reflect'; 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | 4 | yarn-error.log 5 | 6 | /dist 7 | /typings 8 | /browser 9 | /no-ssr 10 | /ssr 11 | 12 | package* 13 | effector-reflect-* -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "printWidth": 80, 4 | "semi": true, 5 | "singleQuote": true, 6 | "tabWidth": 2, 7 | "trailingComma": "all", 8 | "useTabs": false 9 | } 10 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-typescript", 5 | "@babel/preset-react" 6 | ], 7 | "plugins": ["@babel/plugin-transform-runtime", "effector/babel-plugin"] 8 | } 9 | -------------------------------------------------------------------------------- /src/no-ssr/index.ts: -------------------------------------------------------------------------------- 1 | import { useStore, useEvent } from 'effector-react'; 2 | import { reflectCreator, createReflectCreator } from '../core'; 3 | 4 | export const reflect = reflectCreator({ useStore, useEvent }); 5 | export const createReflect = createReflectCreator({ useStore, useEvent }); 6 | -------------------------------------------------------------------------------- /src/ssr/index.ts: -------------------------------------------------------------------------------- 1 | import { useStore, useEvent } from 'effector-react/ssr'; 2 | import { createReflectCreator, reflectCreator } from '../core'; 3 | 4 | export const reflect = reflectCreator({ useStore, useEvent }); 5 | export const createReflect = createReflectCreator({ useStore, useEvent }); 6 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@eslint-kit/base", 4 | "@eslint-kit/typescript", 5 | "@eslint-kit/node", 6 | "@eslint-kit/prettier" 7 | ], 8 | "parser": "@typescript-eslint/parser", 9 | "parserOptions": { 10 | "project": "./.config/tsconfig.base.json" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.config/tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "es2015", 5 | "strict": true, 6 | "lib": ["dom", "dom.iterable", "esnext"], 7 | "moduleResolution": "node", 8 | "allowSyntheticDefaultImports": true, 9 | "jsx": "react", 10 | "esModuleInterop": true, 11 | "strictNullChecks": true 12 | }, 13 | "include": ["../src"], 14 | "exclude": ["../typings", "../**/*.test.tsx"] 15 | } 16 | -------------------------------------------------------------------------------- /src/core/create-reflect/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | View, 3 | BindByProps, 4 | reflectCreator, 5 | ReflectCreatorContext, 6 | ReflectConfig, 7 | } from '../reflect'; 8 | 9 | export function createReflectCreator(context: ReflectCreatorContext) { 10 | const reflect = reflectCreator(context); 11 | 12 | return function createReflect(view: View) { 13 | return = BindByProps>( 14 | bind: Bind, 15 | params?: Pick, 'hooks'>, 16 | ) => reflect({ view, bind, ...params }); 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-anonymous-default-export */ 2 | 3 | import typescript from 'rollup-plugin-typescript2'; 4 | import multiInput from 'rollup-plugin-multi-input'; 5 | import babel from 'rollup-plugin-babel'; 6 | 7 | const { exportRoot } = require('./lib/rollup-plugin-export-root'); 8 | const babelConfig = require('./babel.config.json'); 9 | 10 | export default { 11 | input: ['./src/no-ssr/index.ts', './src/ssr/index.ts'], 12 | output: { dir: './dist', format: 'cjs' }, 13 | watch: false, 14 | plugins: [ 15 | typescript({ 16 | tsconfigDefaults: { compilerOptions: { declaration: true } }, 17 | }), 18 | babel({ 19 | exclude: 'node_modules/**', 20 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 21 | runtimeHelpers: true, 22 | ...babelConfig, 23 | }), 24 | multiInput(), 25 | exportRoot(), 26 | ], 27 | }; 28 | -------------------------------------------------------------------------------- /.github/workflows/change-version.yml: -------------------------------------------------------------------------------- 1 | name: Build and publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | 12 | strategy: 13 | matrix: 14 | node-version: [10.x] 15 | 16 | steps: 17 | - uses: actions/checkout@v1 18 | 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v1 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | registry-url: 'https://registry.npmjs.org' 24 | 25 | - name: install 26 | run: yarn install 27 | 28 | - name: test 29 | run: yarn test ./src 30 | env: 31 | CI: true 32 | 33 | - name: build 34 | run: yarn build 35 | 36 | - name: test build 37 | run: yarn test-build 38 | env: 39 | CI: true 40 | 41 | - name: publish 42 | run: npm publish 43 | env: 44 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 45 | -------------------------------------------------------------------------------- /lib/rollup-plugin-export-root/index.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fse = require('fs-extra'); 3 | 4 | function exportRoot() { 5 | const inputs = { keys: [], paths: [] }; 6 | 7 | return { 8 | name: 'exportRoot', 9 | buildStart: (options) => { 10 | inputs.keys = Object.keys(options.input); 11 | inputs.paths = Object.values(options.input); 12 | }, 13 | generateBundle() { 14 | inputs.keys.forEach((key) => { 15 | this.emitFile({ 16 | type: 'asset', 17 | fileName: `root/${key}.js`, 18 | source: `module.exports = require("../dist/${key}.js");\n`, 19 | }); 20 | 21 | this.emitFile({ 22 | type: 'asset', 23 | fileName: `root/${key}.d.ts`, 24 | source: `export * from "../dist/${path.dirname(key)}";\n`, 25 | }); 26 | }); 27 | }, 28 | writeBundle({ dir }) { 29 | inputs.keys.forEach((key) => { 30 | fse.move( 31 | path.resolve(process.cwd(), dir, `root/${key}.js`), 32 | path.resolve(process.cwd(), `${key}.js`), 33 | ); 34 | 35 | fse.move( 36 | path.resolve(process.cwd(), dir, `root/${key}.d.ts`), 37 | path.resolve(process.cwd(), `${key}.d.ts`), 38 | ); 39 | }); 40 | }, 41 | }; 42 | } 43 | 44 | exports.exportRoot = exportRoot; 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "effector-reflect", 3 | "version": "0.3.2", 4 | "repository": "git@github.com:EvgenyiFedotov/effector-reflect.git", 5 | "author": "e.fedotov ", 6 | "license": "MIT", 7 | "main": "./no-ssr", 8 | "files": [ 9 | "dist", 10 | "no-ssr", 11 | "ssr" 12 | ], 13 | "scripts": { 14 | "test": "jest", 15 | "build": "rm -fr dist no-ssr ssr && yarn rollup --config ./rollup.config.js", 16 | "test-build": "yarn test ./dist-test" 17 | }, 18 | "devDependencies": { 19 | "@babel/core": "^7.12.7", 20 | "@babel/plugin-transform-runtime": "^7.12.1", 21 | "@babel/preset-env": "^7.12.7", 22 | "@babel/preset-react": "^7.12.7", 23 | "@babel/preset-typescript": "^7.12.7", 24 | "@eslint-kit/eslint-config-base": "^2.1.2", 25 | "@eslint-kit/eslint-config-node": "^2.0.0", 26 | "@eslint-kit/eslint-config-prettier": "^2.0.0", 27 | "@eslint-kit/eslint-config-typescript": "^3.1.2", 28 | "@testing-library/react": "^11.2.2", 29 | "@testing-library/user-event": "^12.2.2", 30 | "@types/jest": "^26.0.15", 31 | "@types/react": "^17.0.0", 32 | "@types/react-dom": "^17.0.0", 33 | "@typescript-eslint/parser": "^4.8.1", 34 | "effector": "^21.7.5", 35 | "effector-react": "^21.2.0", 36 | "eslint": "^7.14.0", 37 | "fs-extra": "^9.0.1", 38 | "husky": "^4.3.0", 39 | "jest": "^26.6.3", 40 | "prettier": "^2.2.0", 41 | "react": "^17.0.1", 42 | "react-dom": "^17.0.1", 43 | "rollup": "^2.34.0", 44 | "rollup-plugin-babel": "^4.4.0", 45 | "rollup-plugin-multi-input": "^1.1.1", 46 | "rollup-plugin-typescript2": "^0.29.0", 47 | "ts-jest": "^26.4.4", 48 | "typescript": "^4.1.2", 49 | "uglify-es": "^3.3.9" 50 | }, 51 | "peerDependencies": { 52 | "effector": "^21.7.5", 53 | "effector-react": "^21.2.0", 54 | "react": "^17.0.1", 55 | "react-dom": "^17.0.1" 56 | }, 57 | "husky": { 58 | "hooks": { 59 | "pre-commit": "yarn eslint ./src", 60 | "pre-push": "yarn test ./src" 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/core/reflect/index.ts: -------------------------------------------------------------------------------- 1 | import { FC, ComponentClass, createElement, useEffect } from 'react'; 2 | import { Store, combine, Event, Effect, is } from 'effector'; 3 | import { useEvent, useStore } from 'effector-react'; 4 | 5 | export type BindByProps = { 6 | [Key in keyof Props]?: 7 | | Omit, 'updates' | 'reset' | 'on' | 'off' | 'thru'> 8 | | Props[Key]; 9 | }; 10 | 11 | export type View = FC | ComponentClass; 12 | 13 | export type PropsByBind = Omit & 14 | Partial>>; 15 | 16 | export interface ReflectCreatorContext { 17 | useStore: typeof useStore; 18 | useEvent: typeof useEvent; 19 | } 20 | 21 | export interface ReflectConfig> { 22 | view: View; 23 | bind: Bind; 24 | hooks?: { 25 | mounted?: (() => void) | Event; 26 | unmounted?: (() => void) | Event; 27 | }; 28 | } 29 | 30 | export function reflectCreator(context: ReflectCreatorContext) { 31 | return function reflect< 32 | Props, 33 | Bind extends BindByProps = BindByProps 34 | >(config: ReflectConfig): FC> { 35 | type GenericEvent = Event | Effect; 36 | const events: Record = {}; 37 | const stores: Record> = {}; 38 | const data: Record = {}; 39 | 40 | for (const key in config.bind) { 41 | const value = config.bind[key]; 42 | 43 | if (is.event(value) || is.effect(value)) { 44 | events[key] = value; 45 | } else if (is.store(value)) { 46 | stores[key] = value; 47 | } else { 48 | data[key] = value; 49 | } 50 | } 51 | 52 | const $bind = Object.keys(stores).length > 0 ? combine(stores) : null; 53 | 54 | const hookMounted = config.hooks ? config.hooks.mounted : null; 55 | const mounted = hookMounted 56 | ? () => 57 | useEffect(() => { 58 | hookMounted(); 59 | }, []) 60 | : () => {}; 61 | 62 | const hookUnmounted = config.hooks ? config.hooks.unmounted : null; 63 | const unmounted = hookUnmounted 64 | ? () => 65 | useEffect(() => { 66 | return () => { 67 | hookUnmounted(); 68 | }; 69 | }, []) 70 | : () => {}; 71 | 72 | return (props) => { 73 | const storeProps = $bind ? context.useStore($bind) : ({} as Props); 74 | const eventsProps = context.useEvent(events); 75 | const elementProps = { 76 | ...storeProps, 77 | ...eventsProps, 78 | ...data, 79 | ...props, 80 | } as Props; 81 | 82 | mounted(); 83 | unmounted(); 84 | 85 | return createElement(config.view, elementProps); 86 | }; 87 | }; 88 | } 89 | -------------------------------------------------------------------------------- /dist-test/ssr/create-reflect.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, InputHTMLAttributes, ChangeEvent } from 'react'; 2 | import { restore, fork, allSettled, createDomain } from 'effector'; 3 | import { Provider } from 'effector-react/ssr'; 4 | 5 | import { render } from '@testing-library/react'; 6 | 7 | import { createReflect } from '../../ssr'; 8 | 9 | // Example1 (InputCustom) 10 | const InputCustom: FC<{ 11 | value: string | number | string[]; 12 | onChange(value: string): void; 13 | testId: string; 14 | placeholder?: string; 15 | }> = (props) => { 16 | return ( 17 | props.onChange(event.currentTarget.value)} 22 | /> 23 | ); 24 | }; 25 | 26 | const inputCustom = createReflect(InputCustom); 27 | 28 | test('InputCustom', async () => { 29 | const app = createDomain(); 30 | 31 | const change = app.createEvent(); 32 | const $name = restore(change, ''); 33 | 34 | const Name = inputCustom({ value: $name, onChange: change }); 35 | 36 | const scope = fork(app); 37 | 38 | expect(scope.getState($name)).toBe(''); 39 | await allSettled(change, { scope, params: 'Bob' }); 40 | expect(scope.getState($name)).toBe('Bob'); 41 | 42 | const container = render( 43 | 44 | 45 | , 46 | ); 47 | 48 | const inputName = container.container.firstChild as HTMLInputElement; 49 | expect(inputName.value).toBe('Bob'); 50 | }); 51 | 52 | test('InputCustom [replace value]', async () => { 53 | const app = createDomain(); 54 | 55 | const change = app.createEvent(); 56 | const $name = app.createStore(''); 57 | 58 | $name.on(change, (_, next) => next); 59 | 60 | const Name = inputCustom({ name: $name, onChange: change }); 61 | 62 | const scope = fork(app); 63 | 64 | expect(scope.getState($name)).toBe(''); 65 | await allSettled(change, { scope, params: 'Bob' }); 66 | expect(scope.getState($name)).toBe('Bob'); 67 | 68 | const container = render( 69 | 70 | 71 | , 72 | ); 73 | 74 | const inputName = container.container.firstChild as HTMLInputElement; 75 | expect(inputName.value).toBe('Alise'); 76 | }); 77 | 78 | // Example 2 (InputBase) 79 | const InputBase: FC> = (props) => { 80 | return ; 81 | }; 82 | 83 | const inputBase = createReflect(InputBase); 84 | 85 | test('InputBase', async () => { 86 | const app = createDomain(); 87 | 88 | const changeName = app.createEvent(); 89 | const $name = restore(changeName, ''); 90 | 91 | const inputChanged = (event: ChangeEvent) => { 92 | return event.currentTarget.value; 93 | }; 94 | 95 | const Name = inputBase({ 96 | value: $name, 97 | onChange: changeName.prepend(inputChanged), 98 | }); 99 | 100 | const changeAge = app.createEvent(); 101 | const $age = restore(changeAge, 0); 102 | 103 | const Age = inputBase({ 104 | value: $age, 105 | onChange: changeAge.prepend(parseInt).prepend(inputChanged), 106 | }); 107 | 108 | const scope = fork(app); 109 | 110 | expect(scope.getState($name)).toBe(''); 111 | await allSettled(changeName, { scope, params: 'Bob' }); 112 | expect(scope.getState($name)).toBe('Bob'); 113 | 114 | expect(scope.getState($age)).toBe(0); 115 | await allSettled(changeAge, { scope, params: 25 }); 116 | expect(scope.getState($age)).toBe(25); 117 | 118 | const container = render( 119 | 120 | 121 | 122 | , 123 | ); 124 | 125 | const inputName = container.getByTestId('name') as HTMLInputElement; 126 | expect(inputName.value).toBe('Bob'); 127 | 128 | const inputAge = container.getByTestId('age') as HTMLInputElement; 129 | expect(inputAge.value).toBe('25'); 130 | }); 131 | -------------------------------------------------------------------------------- /src/ssr/create-reflect.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, InputHTMLAttributes, ChangeEvent } from 'react'; 2 | import { restore, fork, allSettled, createDomain } from 'effector'; 3 | import { Provider } from 'effector-react/ssr'; 4 | 5 | import { render } from '@testing-library/react'; 6 | import userEvent from '@testing-library/user-event'; 7 | 8 | import { createReflect } from './index'; 9 | 10 | // Example1 (InputCustom) 11 | const InputCustom: FC<{ 12 | value: string | number | string[]; 13 | onChange(value: string): void; 14 | testId: string; 15 | placeholder?: string; 16 | }> = (props) => { 17 | return ( 18 | props.onChange(event.currentTarget.value)} 23 | /> 24 | ); 25 | }; 26 | 27 | const inputCustom = createReflect(InputCustom); 28 | 29 | test('InputCustom', async () => { 30 | const app = createDomain(); 31 | 32 | const change = app.createEvent(); 33 | const $name = restore(change, ''); 34 | 35 | const Name = inputCustom({ value: $name, onChange: change }); 36 | 37 | const scope = fork(app); 38 | 39 | expect(scope.getState($name)).toBe(''); 40 | await allSettled(change, { scope, params: 'Bob' }); 41 | expect(scope.getState($name)).toBe('Bob'); 42 | 43 | const container = render( 44 | 45 | 46 | , 47 | ); 48 | 49 | const inputName = container.container.firstChild as HTMLInputElement; 50 | expect(inputName.value).toBe('Bob'); 51 | }); 52 | 53 | test('InputCustom [replace value]', async () => { 54 | const app = createDomain(); 55 | 56 | const change = app.createEvent(); 57 | const $name = app.createStore(''); 58 | 59 | $name.on(change, (_, next) => next); 60 | 61 | const Name = inputCustom({ name: $name, onChange: change }); 62 | 63 | const scope = fork(app); 64 | 65 | expect(scope.getState($name)).toBe(''); 66 | await allSettled(change, { scope, params: 'Bob' }); 67 | expect(scope.getState($name)).toBe('Bob'); 68 | 69 | const container = render( 70 | 71 | 72 | , 73 | ); 74 | 75 | const inputName = container.container.firstChild as HTMLInputElement; 76 | expect(inputName.value).toBe('Alise'); 77 | }); 78 | 79 | // Example 2 (InputBase) 80 | const InputBase: FC> = (props) => { 81 | return ; 82 | }; 83 | 84 | const inputBase = createReflect(InputBase); 85 | 86 | test('InputBase', async () => { 87 | const app = createDomain(); 88 | 89 | const changeName = app.createEvent(); 90 | const $name = restore(changeName, ''); 91 | 92 | const inputChanged = (event: ChangeEvent) => { 93 | return event.currentTarget.value; 94 | }; 95 | 96 | const Name = inputBase({ 97 | value: $name, 98 | onChange: changeName.prepend(inputChanged), 99 | }); 100 | 101 | const changeAge = app.createEvent(); 102 | const $age = restore(changeAge, 0); 103 | 104 | const Age = inputBase({ 105 | value: $age, 106 | onChange: changeAge.prepend(parseInt).prepend(inputChanged), 107 | }); 108 | 109 | const scope = fork(app); 110 | 111 | expect(scope.getState($name)).toBe(''); 112 | await allSettled(changeName, { scope, params: 'Bob' }); 113 | expect(scope.getState($name)).toBe('Bob'); 114 | 115 | expect(scope.getState($age)).toBe(0); 116 | await allSettled(changeAge, { scope, params: 25 }); 117 | expect(scope.getState($age)).toBe(25); 118 | 119 | const container = render( 120 | 121 | 122 | 123 | , 124 | ); 125 | 126 | const inputName = container.getByTestId('name') as HTMLInputElement; 127 | expect(inputName.value).toBe('Bob'); 128 | 129 | const inputAge = container.getByTestId('age') as HTMLInputElement; 130 | expect(inputAge.value).toBe('25'); 131 | }); 132 | 133 | test('with ssr for client', async () => { 134 | const app = createDomain(); 135 | 136 | const changeName = app.createEvent(); 137 | const $name = restore(changeName, ''); 138 | 139 | const Name = inputBase({ 140 | value: $name, 141 | onChange: changeName.prepend((event) => event.currentTarget.value), 142 | }); 143 | 144 | const scope = fork(app); 145 | 146 | const container = render( 147 | 148 | 149 | , 150 | ); 151 | 152 | expect($name.getState()).toBe(''); 153 | await userEvent.type(container.getByTestId('name'), 'Bob'); 154 | expect(scope.getState($name)).toBe('Bob'); 155 | 156 | const inputName = container.getByTestId('name') as HTMLInputElement; 157 | expect(inputName.value).toBe('Bob'); 158 | }); 159 | -------------------------------------------------------------------------------- /src/no-ssr/create-reflect.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, InputHTMLAttributes } from 'react'; 2 | import { createStore, createEvent, restore, createEffect } from 'effector'; 3 | import { act } from 'react-dom/test-utils'; 4 | 5 | import { render } from '@testing-library/react'; 6 | import userEvent from '@testing-library/user-event'; 7 | 8 | import { createReflect } from './index'; 9 | 10 | // Example1 (InputCustom) 11 | const InputCustom: FC<{ 12 | value: string | number | string[]; 13 | onChange(value: string): void; 14 | testId: string; 15 | placeholder?: string; 16 | }> = (props) => { 17 | return ( 18 | props.onChange(event.currentTarget.value)} 23 | /> 24 | ); 25 | }; 26 | 27 | const inputCustom = createReflect(InputCustom); 28 | 29 | test('InputCustom', async () => { 30 | const change = createEvent(); 31 | const $name = restore(change, ''); 32 | 33 | const Name = inputCustom({ value: $name, onChange: change }); 34 | 35 | const container = render(); 36 | 37 | expect($name.getState()).toBe(''); 38 | await userEvent.type(container.getByTestId('name'), 'Bob'); 39 | expect($name.getState()).toBe('Bob'); 40 | 41 | const inputName = container.container.firstChild as HTMLInputElement; 42 | expect(inputName.value).toBe('Bob'); 43 | }); 44 | 45 | test('InputCustom [replace value]', async () => { 46 | const change = createEvent(); 47 | const $name = createStore(''); 48 | 49 | $name.on(change, (_, next) => next); 50 | 51 | const Name = inputCustom({ name: $name, onChange: change }); 52 | 53 | const container = render(); 54 | 55 | expect($name.getState()).toBe(''); 56 | await userEvent.type(container.getByTestId('name'), 'Bob'); 57 | expect($name.getState()).toBe('Aliseb'); 58 | 59 | const inputName = container.container.firstChild as HTMLInputElement; 60 | expect(inputName.value).toBe('Alise'); 61 | }); 62 | 63 | // Example 2 (InputBase) 64 | const InputBase: FC> = (props) => { 65 | return ; 66 | }; 67 | 68 | const inputBase = createReflect(InputBase); 69 | 70 | test('InputBase', async () => { 71 | const changeName = createEvent(); 72 | const $name = restore(changeName, ''); 73 | 74 | const Name = inputBase({ 75 | value: $name, 76 | onChange: (event) => changeName(event.currentTarget.value), 77 | }); 78 | 79 | const changeAge = createEvent(); 80 | const $age = restore(changeAge, 0); 81 | const Age = inputBase({ 82 | value: $age, 83 | onChange: (event) => { 84 | changeAge(Number.parseInt(event.currentTarget.value, 10)); 85 | }, 86 | }); 87 | 88 | const container = render( 89 | <> 90 | 91 | 92 | , 93 | ); 94 | 95 | expect($name.getState()).toBe(''); 96 | await userEvent.type(container.getByTestId('name'), 'Bob'); 97 | expect($name.getState()).toBe('Bob'); 98 | 99 | expect($age.getState()).toBe(0); 100 | await userEvent.type(container.getByTestId('age'), '25'); 101 | expect($age.getState()).toBe(25); 102 | 103 | const inputName = container.getByTestId('name') as HTMLInputElement; 104 | expect(inputName.value).toBe('Bob'); 105 | 106 | const inputAge = container.getByTestId('age') as HTMLInputElement; 107 | expect(inputAge.value).toBe('25'); 108 | }); 109 | 110 | describe('hooks', () => { 111 | describe('mounted', () => { 112 | test('callback', () => { 113 | const changeName = createEvent(); 114 | const $name = restore(changeName, ''); 115 | 116 | const mounted = jest.fn(() => {}); 117 | 118 | const Name = inputBase( 119 | { 120 | value: $name, 121 | onChange: changeName.prepend((event) => event.currentTarget.value), 122 | }, 123 | { hooks: { mounted } }, 124 | ); 125 | 126 | render(); 127 | 128 | expect(mounted.mock.calls.length).toBe(1); 129 | }); 130 | 131 | test('event', () => { 132 | const changeName = createEvent(); 133 | const $name = restore(changeName, ''); 134 | const mounted = createEvent(); 135 | 136 | const fn = jest.fn(() => {}); 137 | 138 | mounted.watch(fn); 139 | 140 | const Name = inputBase( 141 | { 142 | value: $name, 143 | onChange: changeName.prepend((event) => event.currentTarget.value), 144 | }, 145 | { hooks: { mounted } }, 146 | ); 147 | 148 | render(); 149 | 150 | expect(fn.mock.calls.length).toBe(1); 151 | }); 152 | }); 153 | 154 | describe('unmounted', () => { 155 | const changeVisible = createEffect({ handler: () => {} }); 156 | const $visible = restore( 157 | changeVisible.finally.map(({ params }) => params), 158 | true, 159 | ); 160 | 161 | const Branch = createReflect<{ visible: boolean }>( 162 | ({ visible, children }) => (visible ? <>{children} : null), 163 | )({ 164 | visible: $visible, 165 | }); 166 | 167 | beforeEach(() => { 168 | act(() => { 169 | changeVisible(true); 170 | }); 171 | }); 172 | 173 | test('callback', () => { 174 | const changeName = createEvent(); 175 | const $name = restore(changeName, ''); 176 | 177 | const unmounted = jest.fn(() => {}); 178 | 179 | const Name = inputBase( 180 | { 181 | value: $name, 182 | onChange: changeName.prepend((event) => event.currentTarget.value), 183 | }, 184 | { hooks: { unmounted } }, 185 | ); 186 | 187 | render(, { wrapper: Branch }); 188 | 189 | act(() => { 190 | changeVisible(false); 191 | }); 192 | 193 | expect(unmounted.mock.calls.length).toBe(1); 194 | }); 195 | 196 | test('event', () => { 197 | const changeName = createEvent(); 198 | const $name = restore(changeName, ''); 199 | 200 | const unmounted = createEvent(); 201 | const fn = jest.fn(() => {}); 202 | 203 | unmounted.watch(fn); 204 | 205 | const Name = inputBase( 206 | { 207 | value: $name, 208 | onChange: changeName.prepend((event) => event.currentTarget.value), 209 | }, 210 | { hooks: { unmounted } }, 211 | ); 212 | 213 | render(, { wrapper: Branch }); 214 | 215 | act(() => { 216 | changeVisible(false); 217 | }); 218 | 219 | expect(fn.mock.calls.length).toBe(1); 220 | }); 221 | }); 222 | }); 223 | -------------------------------------------------------------------------------- /dist-test/no-ssr/create-reflect.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, InputHTMLAttributes } from 'react'; 2 | import { createStore, createEvent, restore, createEffect } from 'effector'; 3 | import { act } from 'react-dom/test-utils'; 4 | 5 | import { render } from '@testing-library/react'; 6 | import userEvent from '@testing-library/user-event'; 7 | 8 | import { createReflect } from '../../no-ssr'; 9 | 10 | // Example1 (InputCustom) 11 | const InputCustom: FC<{ 12 | value: string | number | string[]; 13 | onChange(value: string): void; 14 | testId: string; 15 | placeholder?: string; 16 | }> = (props) => { 17 | return ( 18 | props.onChange(event.currentTarget.value)} 23 | /> 24 | ); 25 | }; 26 | 27 | const inputCustom = createReflect(InputCustom); 28 | 29 | test('InputCustom', async () => { 30 | const change = createEvent(); 31 | const $name = restore(change, ''); 32 | 33 | const Name = inputCustom({ value: $name, onChange: change }); 34 | 35 | const container = render(); 36 | 37 | expect($name.getState()).toBe(''); 38 | await userEvent.type(container.getByTestId('name'), 'Bob'); 39 | expect($name.getState()).toBe('Bob'); 40 | 41 | const inputName = container.container.firstChild as HTMLInputElement; 42 | expect(inputName.value).toBe('Bob'); 43 | }); 44 | 45 | test('InputCustom [replace value]', async () => { 46 | const change = createEvent(); 47 | const $name = createStore(''); 48 | 49 | $name.on(change, (_, next) => next); 50 | 51 | const Name = inputCustom({ name: $name, onChange: change }); 52 | 53 | const container = render(); 54 | 55 | expect($name.getState()).toBe(''); 56 | await userEvent.type(container.getByTestId('name'), 'Bob'); 57 | expect($name.getState()).toBe('Aliseb'); 58 | 59 | const inputName = container.container.firstChild as HTMLInputElement; 60 | expect(inputName.value).toBe('Alise'); 61 | }); 62 | 63 | // Example 2 (InputBase) 64 | const InputBase: FC> = (props) => { 65 | return ; 66 | }; 67 | 68 | const inputBase = createReflect(InputBase); 69 | 70 | test('InputBase', async () => { 71 | const changeName = createEvent(); 72 | const $name = restore(changeName, ''); 73 | 74 | const Name = inputBase({ 75 | value: $name, 76 | onChange: (event) => changeName(event.currentTarget.value), 77 | }); 78 | 79 | const changeAge = createEvent(); 80 | const $age = restore(changeAge, 0); 81 | const Age = inputBase({ 82 | value: $age, 83 | onChange: (event) => { 84 | changeAge(Number.parseInt(event.currentTarget.value, 10)); 85 | }, 86 | }); 87 | 88 | const container = render( 89 | <> 90 | 91 | 92 | , 93 | ); 94 | 95 | expect($name.getState()).toBe(''); 96 | await userEvent.type(container.getByTestId('name'), 'Bob'); 97 | expect($name.getState()).toBe('Bob'); 98 | 99 | expect($age.getState()).toBe(0); 100 | await userEvent.type(container.getByTestId('age'), '25'); 101 | expect($age.getState()).toBe(25); 102 | 103 | const inputName = container.getByTestId('name') as HTMLInputElement; 104 | expect(inputName.value).toBe('Bob'); 105 | 106 | const inputAge = container.getByTestId('age') as HTMLInputElement; 107 | expect(inputAge.value).toBe('25'); 108 | }); 109 | 110 | describe('hooks', () => { 111 | describe('mounted', () => { 112 | test('callback', () => { 113 | const changeName = createEvent(); 114 | const $name = restore(changeName, ''); 115 | 116 | const mounted = jest.fn(() => {}); 117 | 118 | const Name = inputBase( 119 | { 120 | value: $name, 121 | onChange: changeName.prepend((event) => event.currentTarget.value), 122 | }, 123 | { hooks: { mounted } }, 124 | ); 125 | 126 | render(); 127 | 128 | expect(mounted.mock.calls.length).toBe(1); 129 | }); 130 | 131 | test('event', () => { 132 | const changeName = createEvent(); 133 | const $name = restore(changeName, ''); 134 | const mounted = createEvent(); 135 | 136 | const fn = jest.fn(() => {}); 137 | 138 | mounted.watch(fn); 139 | 140 | const Name = inputBase( 141 | { 142 | value: $name, 143 | onChange: changeName.prepend((event) => event.currentTarget.value), 144 | }, 145 | { hooks: { mounted } }, 146 | ); 147 | 148 | render(); 149 | 150 | expect(fn.mock.calls.length).toBe(1); 151 | }); 152 | }); 153 | 154 | describe('unmounted', () => { 155 | const changeVisible = createEffect({ handler: () => {} }); 156 | const $visible = restore( 157 | changeVisible.finally.map(({ params }) => params), 158 | true, 159 | ); 160 | 161 | const Branch = createReflect<{ visible: boolean }>( 162 | ({ visible, children }) => (visible ? <>{children} : null), 163 | )({ 164 | visible: $visible, 165 | }); 166 | 167 | beforeEach(() => { 168 | act(() => { 169 | changeVisible(true); 170 | }); 171 | }); 172 | 173 | test('callback', () => { 174 | const changeName = createEvent(); 175 | const $name = restore(changeName, ''); 176 | 177 | const unmounted = jest.fn(() => {}); 178 | 179 | const Name = inputBase( 180 | { 181 | value: $name, 182 | onChange: changeName.prepend((event) => event.currentTarget.value), 183 | }, 184 | { hooks: { unmounted } }, 185 | ); 186 | 187 | render(, { wrapper: Branch }); 188 | 189 | act(() => { 190 | changeVisible(false); 191 | }); 192 | 193 | expect(unmounted.mock.calls.length).toBe(1); 194 | }); 195 | 196 | test('event', () => { 197 | const changeName = createEvent(); 198 | const $name = restore(changeName, ''); 199 | 200 | const unmounted = createEvent(); 201 | const fn = jest.fn(() => {}); 202 | 203 | unmounted.watch(fn); 204 | 205 | const Name = inputBase( 206 | { 207 | value: $name, 208 | onChange: changeName.prepend((event) => event.currentTarget.value), 209 | }, 210 | { hooks: { unmounted } }, 211 | ); 212 | 213 | render(, { wrapper: Branch }); 214 | 215 | act(() => { 216 | changeVisible(false); 217 | }); 218 | 219 | expect(fn.mock.calls.length).toBe(1); 220 | }); 221 | }); 222 | }); 223 | -------------------------------------------------------------------------------- /dist-test/no-ssr/reflect.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, InputHTMLAttributes, ChangeEvent } from 'react'; 2 | import { createStore, createEvent, restore, createEffect } from 'effector'; 3 | import { act } from 'react-dom/test-utils'; 4 | 5 | import { render } from '@testing-library/react'; 6 | import userEvent from '@testing-library/user-event'; 7 | 8 | import { reflect } from '../../no-ssr'; 9 | 10 | // Example1 (InputCustom) 11 | const InputCustom: FC<{ 12 | value: string | number | string[]; 13 | onChange(value: string): void; 14 | testId: string; 15 | placeholder?: string; 16 | }> = (props) => { 17 | return ( 18 | props.onChange(event.currentTarget.value)} 23 | /> 24 | ); 25 | }; 26 | 27 | test('InputCustom', async () => { 28 | const change = createEvent(); 29 | const $name = restore(change, ''); 30 | 31 | const Name = reflect({ 32 | view: InputCustom, 33 | bind: { value: $name, onChange: change }, 34 | }); 35 | 36 | const container = render(); 37 | 38 | expect($name.getState()).toBe(''); 39 | await userEvent.type(container.getByTestId('name'), 'Bob'); 40 | expect($name.getState()).toBe('Bob'); 41 | 42 | const inputName = container.container.firstChild as HTMLInputElement; 43 | expect(inputName.value).toBe('Bob'); 44 | }); 45 | 46 | test('InputCustom [replace value]', async () => { 47 | const change = createEvent(); 48 | const $name = createStore(''); 49 | 50 | $name.on(change, (_, next) => next); 51 | 52 | const Name = reflect({ 53 | view: InputCustom, 54 | bind: { name: $name, onChange: change }, 55 | }); 56 | 57 | const container = render(); 58 | 59 | expect($name.getState()).toBe(''); 60 | await userEvent.type(container.getByTestId('name'), 'Bob'); 61 | expect($name.getState()).toBe('Aliseb'); 62 | 63 | const inputName = container.container.firstChild as HTMLInputElement; 64 | expect(inputName.value).toBe('Alise'); 65 | }); 66 | 67 | // Example 2 (InputBase) 68 | const InputBase: FC> = (props) => { 69 | return ; 70 | }; 71 | 72 | test('InputBase', async () => { 73 | const changeName = createEvent(); 74 | const $name = restore(changeName, ''); 75 | 76 | const inputChanged = (event: ChangeEvent) => { 77 | return event.currentTarget.value; 78 | }; 79 | 80 | const Name = reflect({ 81 | view: InputBase, 82 | bind: { 83 | value: $name, 84 | onChange: changeName.prepend(inputChanged), 85 | }, 86 | }); 87 | 88 | const changeAge = createEvent(); 89 | const $age = restore(changeAge, 0); 90 | const Age = reflect({ 91 | view: InputBase, 92 | bind: { 93 | value: $age, 94 | onChange: changeAge.prepend(parseInt).prepend(inputChanged), 95 | }, 96 | }); 97 | 98 | const container = render( 99 | <> 100 | 101 | 102 | , 103 | ); 104 | 105 | expect($name.getState()).toBe(''); 106 | await userEvent.type(container.getByTestId('name'), 'Bob'); 107 | expect($name.getState()).toBe('Bob'); 108 | 109 | expect($age.getState()).toBe(0); 110 | await userEvent.type(container.getByTestId('age'), '25'); 111 | expect($age.getState()).toBe(25); 112 | 113 | const inputName = container.getByTestId('name') as HTMLInputElement; 114 | expect(inputName.value).toBe('Bob'); 115 | 116 | const inputAge = container.getByTestId('age') as HTMLInputElement; 117 | expect(inputAge.value).toBe('25'); 118 | }); 119 | 120 | describe('hooks', () => { 121 | describe('mounted', () => { 122 | test('callback', () => { 123 | const changeName = createEvent(); 124 | const $name = restore(changeName, ''); 125 | 126 | const mounted = jest.fn(() => {}); 127 | 128 | const Name = reflect({ 129 | view: InputBase, 130 | bind: { 131 | value: $name, 132 | onChange: changeName.prepend((event) => event.currentTarget.value), 133 | }, 134 | hooks: { mounted }, 135 | }); 136 | 137 | render(); 138 | 139 | expect(mounted.mock.calls.length).toBe(1); 140 | }); 141 | 142 | test('event', () => { 143 | const changeName = createEvent(); 144 | const $name = restore(changeName, ''); 145 | const mounted = createEvent(); 146 | 147 | const fn = jest.fn(() => {}); 148 | 149 | mounted.watch(fn); 150 | 151 | const Name = reflect({ 152 | view: InputBase, 153 | bind: { 154 | value: $name, 155 | onChange: changeName.prepend((event) => event.currentTarget.value), 156 | }, 157 | hooks: { mounted }, 158 | }); 159 | 160 | render(); 161 | 162 | expect(fn.mock.calls.length).toBe(1); 163 | }); 164 | }); 165 | 166 | describe('unmounted', () => { 167 | const changeVisible = createEffect({ handler: () => {} }); 168 | const $visible = restore( 169 | changeVisible.finally.map(({ params }) => params), 170 | true, 171 | ); 172 | 173 | const Branch = reflect<{ visible: boolean }>({ 174 | view: ({ visible, children }) => (visible ? <>{children} : null), 175 | bind: { visible: $visible }, 176 | }); 177 | 178 | beforeEach(() => { 179 | act(() => { 180 | changeVisible(true); 181 | }); 182 | }); 183 | 184 | test('callback', () => { 185 | const changeName = createEvent(); 186 | const $name = restore(changeName, ''); 187 | 188 | const unmounted = jest.fn(() => {}); 189 | 190 | const Name = reflect({ 191 | view: InputBase, 192 | bind: { 193 | value: $name, 194 | onChange: changeName.prepend((event) => event.currentTarget.value), 195 | }, 196 | hooks: { unmounted }, 197 | }); 198 | 199 | render(, { wrapper: Branch }); 200 | 201 | act(() => { 202 | changeVisible(false); 203 | }); 204 | 205 | expect(unmounted.mock.calls.length).toBe(1); 206 | }); 207 | 208 | test('event', () => { 209 | const changeName = createEvent(); 210 | const $name = restore(changeName, ''); 211 | 212 | const unmounted = createEvent(); 213 | const fn = jest.fn(() => {}); 214 | 215 | unmounted.watch(fn); 216 | 217 | const Name = reflect({ 218 | view: InputBase, 219 | bind: { 220 | value: $name, 221 | onChange: changeName.prepend((event) => event.currentTarget.value), 222 | }, 223 | hooks: { unmounted }, 224 | }); 225 | 226 | render(, { wrapper: Branch }); 227 | 228 | act(() => { 229 | changeVisible(false); 230 | }); 231 | 232 | expect(fn.mock.calls.length).toBe(1); 233 | }); 234 | }); 235 | }); 236 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Effector-reflect 2 | 3 | ☄️ Render react-components by effector stores. 4 | 5 | ## Install 6 | 7 | ### Npm 8 | 9 | ```sh 10 | npm install effector-reflect 11 | ``` 12 | 13 | ### Yarn 14 | 15 | ```sh 16 | yarn add effector-reflect 17 | ``` 18 | 19 | ## Motivation 20 | 21 | ### Common ui 22 | 23 | ```tsx 24 | // ./ui.ts 25 | import React, { FC, ChangeEvent, useCallback } from 'react'; 26 | 27 | type InputProps = { 28 | value: string; 29 | onChange: ChangeEvent; 30 | }; 31 | 32 | export const Input: FC = ({ value, onChange }) => { 33 | return ; 34 | }; 35 | ``` 36 | 37 | ### Old case 38 | 39 | ```tsx 40 | // ./old-case.ts 41 | import React, { FC, ChangeEvent, useCallback } from 'react'; 42 | import { createEvent, restore } from 'effector'; 43 | import { useStore } from 'effector-react'; 44 | 45 | import { Input } from './ui'; 46 | 47 | // Model 48 | const changeName = createEvent(); 49 | const $name = restore(changeName, ''); 50 | 51 | // Component 52 | export const Name: FC = () => { 53 | const value = useStore($name); 54 | const changed = useCallback( 55 | (event: ChangeEvent) => changeName(event.target.value), 56 | [], 57 | ); 58 | 59 | return ; 60 | }; 61 | ``` 62 | 63 | ### New case 64 | 65 | ```tsx 66 | // ./new-case.ts 67 | import { createEvent, restore } from 'effector'; 68 | import { reflect } from 'effector-reflect'; 69 | 70 | import { Input } from './ui'; 71 | 72 | // Model 73 | const changeName = createEvent(); 74 | const $name = restore(changeName, ''); 75 | 76 | // Component 77 | export const Name = reflect({ 78 | view: Input, 79 | bind: { value: $name, onChange: (event) => changeName(event.target.value) }, 80 | }); 81 | ``` 82 | 83 | ## Reflect 84 | 85 | Method for bind stores to a view. 86 | 87 | ```tsx 88 | // ./user.tsx 89 | import React, { FC, useCallback, ChangeEvent } from 'react'; 90 | import { createEvent, restore } from 'effector'; 91 | import { reflect } from 'effector-reflect'; 92 | 93 | // Base components 94 | type InputProps = { 95 | value: string; 96 | onChange: ChangeEvent; 97 | placeholder?: string; 98 | }; 99 | 100 | const Input: FC = ({ value, onChange, placeholder }) => { 101 | return ; 102 | }; 103 | 104 | // Model 105 | const changeName = createEvent(); 106 | const $name = restore(changeName, ''); 107 | 108 | const changeAge = createEvent(); 109 | const $age = restore(changeAge, 0); 110 | 111 | const inputChanged = (event: ChangeEvent) => { 112 | return event.currentTarget.value; 113 | }; 114 | 115 | // Components 116 | const Name = reflect({ 117 | view: Input, 118 | bind: { 119 | value: $name, 120 | onChange: changeName.prepend(inputChanged), 121 | }, 122 | }); 123 | 124 | const Age = reflect({ 125 | view: Input, 126 | bind: { 127 | value: $age, 128 | onChange: changeAge.prepend(parseInt).prepend(inputChanged), 129 | }, 130 | }); 131 | 132 | export const User: FC = () => { 133 | return ( 134 |
135 | 136 | 137 |
138 | ); 139 | }; 140 | ``` 141 | 142 | ## Create reflect 143 | 144 | Method for creating reflect a view. So you can create a UI kit by views and use a view with a store already. 145 | 146 | ```tsx 147 | // ./ui.tsx 148 | import React, { FC, useCallback, ChangeEvent, MouseEvent } from 'react'; 149 | import { createReflect } from 'effector-reflect'; 150 | 151 | // Input 152 | type InputProps = { 153 | value: string; 154 | onChange: ChangeEvent; 155 | }; 156 | 157 | const Input: FC = ({ value, onChange }) => { 158 | return ; 159 | }; 160 | 161 | export const reflectInput = createReflect(Input); 162 | 163 | // Button 164 | type ButtonProps = { 165 | onClick: MouseEvent; 166 | title?: string; 167 | }; 168 | 169 | const Button: FC = ({ onClick, children, title }) => { 170 | return ( 171 | 174 | ); 175 | }; 176 | 177 | export const reflectButton = createReflect(Button); 178 | ``` 179 | 180 | ```tsx 181 | // ./user.tsx 182 | import React, { FC } from 'react'; 183 | import { createEvent, restore } from 'effector'; 184 | 185 | import { reflectInput, reflectButton } from './ui'; 186 | 187 | // Model 188 | const changeName = createEvent(); 189 | const $name = restore(changeName, ''); 190 | 191 | const changeAge = createEvent(); 192 | const $age = restore(changeAge, 0); 193 | 194 | const submit = createEvent(); 195 | 196 | // Components 197 | const Name = reflectInput({ 198 | value: $name, 199 | onChange: (event) => changeName(event.target.value), 200 | }); 201 | 202 | const Age = reflectInput({ 203 | value: $age, 204 | onChange: (event) => changeAge(parsetInt(event.target.value)), 205 | }); 206 | 207 | const Submit = reflectButton({ 208 | onClick: () => submit(), 209 | }); 210 | 211 | export const User: FC = () => { 212 | return ( 213 |
214 | 215 | 216 | Save left 217 | Save right 218 |
219 | ); 220 | }; 221 | ``` 222 | 223 | ## SSR 224 | 225 | For SSR need to replace imports `effector-reflect` -> `effector-reflect/ssr`. 226 | Also use `event.prepend(params => params)` instead `(params) => event(params)`. 227 | 228 | ```tsx 229 | // ./ui.tsx 230 | import React, { FC, useCallback, ChangeEvent, MouseEvent } from 'react'; 231 | 232 | // Input 233 | type InputProps = { 234 | value: string; 235 | onChange: ChangeEvent; 236 | }; 237 | 238 | const Input: FC = ({ value, onChange }) => { 239 | return ; 240 | }; 241 | ``` 242 | 243 | ```tsx 244 | // ./app.tsx 245 | import React, { FC } from 'react'; 246 | import { createEvent, restore, Fork, createDomain } from 'effector'; 247 | import { reflect } from 'effector-reflect/ssr'; 248 | import { Provider } from 'effector-react/ssr'; 249 | 250 | import { Input } from './ui'; 251 | 252 | // Model 253 | export const app = createDomain(); 254 | 255 | export const changeName = app.createEvent(); 256 | const $name = restore(changeName, ''); 257 | 258 | // Component 259 | const Name = reflect({ 260 | view: Input, 261 | bind: { value: $name, onChange: changeName.prepend(event => event.target.value) }, 262 | }); 263 | 264 | export const App: FC<{ data: Fork }> = ({ data }) => { 265 | return ( 266 | 267 | 268 | 269 | ); 270 | }; 271 | ``` 272 | 273 | ```tsx 274 | // ./server.ts 275 | import { fork, serialize, allSettled } from 'effector/fork'; 276 | 277 | import { App, app, changeName } from './app'; 278 | 279 | const render = async () => { 280 | const scope = fork(app); 281 | 282 | await allSettled(changeName, { scope, params: 'Bob' }); 283 | 284 | const data = serialize(scope); 285 | 286 | const content = renderToString(); 287 | 288 | return ` 289 | 290 | ${content} 291 | 294 | 295 | `; 296 | }; 297 | ``` 298 | 299 | ## Roadmap 300 | 301 | - [] Auto moving test from ./src to ./dist-test 302 | -------------------------------------------------------------------------------- /src/ssr/reflect.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, InputHTMLAttributes, ChangeEvent } from 'react'; 2 | import { restore, fork, allSettled, createDomain } from 'effector'; 3 | import { Provider } from 'effector-react/ssr'; 4 | 5 | import { render } from '@testing-library/react'; 6 | import userEvent from '@testing-library/user-event'; 7 | 8 | import { reflect } from './index'; 9 | 10 | // Example1 (InputCustom) 11 | const InputCustom: FC<{ 12 | value: string | number | string[]; 13 | onChange(value: string): void; 14 | testId: string; 15 | placeholder?: string; 16 | }> = (props) => { 17 | return ( 18 | props.onChange(event.currentTarget.value)} 23 | /> 24 | ); 25 | }; 26 | 27 | test('InputCustom', async () => { 28 | const app = createDomain(); 29 | 30 | const change = app.createEvent(); 31 | const $name = restore(change, ''); 32 | 33 | const Name = reflect({ 34 | view: InputCustom, 35 | bind: { value: $name, onChange: change }, 36 | }); 37 | 38 | const scope = fork(app); 39 | 40 | expect(scope.getState($name)).toBe(''); 41 | await allSettled(change, { scope, params: 'Bob' }); 42 | expect(scope.getState($name)).toBe('Bob'); 43 | 44 | const container = render( 45 | 46 | 47 | , 48 | ); 49 | 50 | const inputName = container.container.firstChild as HTMLInputElement; 51 | expect(inputName.value).toBe('Bob'); 52 | }); 53 | 54 | test('InputCustom [replace value]', async () => { 55 | const app = createDomain(); 56 | 57 | const change = app.createEvent(); 58 | const $name = app.createStore(''); 59 | 60 | $name.on(change, (_, next) => next); 61 | 62 | const Name = reflect({ 63 | view: InputCustom, 64 | bind: { name: $name, onChange: change }, 65 | }); 66 | 67 | const scope = fork(app); 68 | 69 | expect(scope.getState($name)).toBe(''); 70 | await allSettled(change, { scope, params: 'Bob' }); 71 | expect(scope.getState($name)).toBe('Bob'); 72 | 73 | const container = render( 74 | 75 | 76 | , 77 | ); 78 | 79 | const inputName = container.container.firstChild as HTMLInputElement; 80 | expect(inputName.value).toBe('Alise'); 81 | }); 82 | 83 | // Example 2 (InputBase) 84 | const InputBase: FC> = (props) => { 85 | return ; 86 | }; 87 | 88 | test('InputBase', async () => { 89 | const app = createDomain(); 90 | 91 | const changeName = app.createEvent(); 92 | const $name = restore(changeName, ''); 93 | 94 | const inputChanged = (event: ChangeEvent) => { 95 | return event.currentTarget.value; 96 | }; 97 | 98 | const Name = reflect({ 99 | view: InputBase, 100 | bind: { 101 | value: $name, 102 | onChange: changeName.prepend(inputChanged), 103 | }, 104 | }); 105 | 106 | const changeAge = app.createEvent(); 107 | const $age = restore(changeAge, 0); 108 | 109 | const Age = reflect({ 110 | view: InputBase, 111 | bind: { 112 | value: $age, 113 | onChange: changeAge.prepend(parseInt).prepend(inputChanged), 114 | }, 115 | }); 116 | 117 | const scope = fork(app); 118 | 119 | expect(scope.getState($name)).toBe(''); 120 | await allSettled(changeName, { scope, params: 'Bob' }); 121 | expect(scope.getState($name)).toBe('Bob'); 122 | 123 | expect(scope.getState($age)).toBe(0); 124 | await allSettled(changeAge, { scope, params: 25 }); 125 | expect(scope.getState($age)).toBe(25); 126 | 127 | const container = render( 128 | 129 | 130 | 131 | , 132 | ); 133 | 134 | const inputName = container.getByTestId('name') as HTMLInputElement; 135 | expect(inputName.value).toBe('Bob'); 136 | 137 | const inputAge = container.getByTestId('age') as HTMLInputElement; 138 | expect(inputAge.value).toBe('25'); 139 | }); 140 | 141 | test('with ssr for client', async () => { 142 | const app = createDomain(); 143 | 144 | const changeName = app.createEvent(); 145 | const $name = restore(changeName, ''); 146 | 147 | const Name = reflect({ 148 | view: (props: { 149 | value: string; 150 | onChange: (_event: ChangeEvent) => void; 151 | }) => { 152 | return ( 153 | 158 | ); 159 | }, 160 | bind: { 161 | value: $name, 162 | onChange: changeName.prepend((event) => event.currentTarget.value), 163 | }, 164 | }); 165 | 166 | const scope = fork(app); 167 | 168 | const container = render( 169 | 170 | 171 | , 172 | ); 173 | 174 | expect(scope.getState($name)).toBe(''); 175 | await userEvent.type(container.getByTestId('name'), 'Bob'); 176 | expect(scope.getState($name)).toBe('Bob'); 177 | 178 | const inputName = container.getByTestId('name') as HTMLInputElement; 179 | expect(inputName.value).toBe('Bob'); 180 | }); 181 | 182 | test('two scopes', async () => { 183 | const app = createDomain(); 184 | 185 | const changeName = app.createEvent(); 186 | const $name = restore(changeName, ''); 187 | 188 | const Name = reflect({ 189 | view: InputCustom, 190 | bind: { value: $name, onChange: changeName }, 191 | }); 192 | 193 | const scope1 = fork(app); 194 | const scope2 = fork(app); 195 | 196 | expect(scope2.getState($name)).toBe(''); 197 | await allSettled(changeName, { scope: scope2, params: 'Alise' }); 198 | expect(scope2.getState($name)).toBe('Alise'); 199 | 200 | expect(scope1.getState($name)).toBe(''); 201 | await allSettled(changeName, { scope: scope1, params: 'Bob' }); 202 | expect(scope1.getState($name)).toBe('Bob'); 203 | 204 | const container2 = render( 205 | 206 | 207 | , 208 | ); 209 | const container1 = render( 210 | 211 | 212 | , 213 | ); 214 | 215 | const inputName1 = container1.getByTestId('name1') as HTMLInputElement; 216 | const inputName2 = container2.getByTestId('name2') as HTMLInputElement; 217 | 218 | await allSettled(changeName, { scope: scope2, params: 'Alise' }); 219 | await allSettled(changeName, { scope: scope1, params: 'Bob' }); 220 | 221 | expect(scope1.getState($name)).toBe('Bob'); 222 | expect(scope2.getState($name)).toBe('Alise'); 223 | 224 | expect(inputName1.value).toBe('Bob'); 225 | expect(inputName2.value).toBe('Alise'); 226 | }); 227 | 228 | test('use only event for bind', async () => { 229 | const app = createDomain(); 230 | 231 | const changeName = app.createEvent(); 232 | const $name = restore(changeName, ''); 233 | 234 | const changeAge = app.createEvent(); 235 | 236 | const Name = reflect({ 237 | view: InputBase, 238 | bind: { 239 | value: $name, 240 | onChange: changeName.prepend((event) => event.currentTarget.value), 241 | }, 242 | }); 243 | 244 | const Age = reflect({ 245 | view: InputBase, 246 | bind: { 247 | onChange: changeAge.prepend((event) => 248 | Number.parseInt(event.currentTarget.value, 10), 249 | ), 250 | }, 251 | }); 252 | 253 | const scope = fork(app); 254 | 255 | const container = render( 256 | 257 | 258 | 259 | , 260 | ); 261 | 262 | const name = container.getByTestId('name') as HTMLInputElement; 263 | const age = container.getByTestId('age') as HTMLInputElement; 264 | 265 | expect(name.value).toBe(''); 266 | expect(age.value).toBe(''); 267 | }); 268 | -------------------------------------------------------------------------------- /dist-test/ssr/reflect.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, InputHTMLAttributes, ChangeEvent } from 'react'; 2 | import { restore, fork, allSettled, createDomain } from 'effector'; 3 | import { Provider } from 'effector-react/ssr'; 4 | 5 | import { render } from '@testing-library/react'; 6 | import userEvent from '@testing-library/user-event'; 7 | 8 | import { reflect } from '../../ssr'; 9 | 10 | // Example1 (InputCustom) 11 | const InputCustom: FC<{ 12 | value: string | number | string[]; 13 | onChange(value: string): void; 14 | testId: string; 15 | placeholder?: string; 16 | }> = (props) => { 17 | return ( 18 | props.onChange(event.currentTarget.value)} 23 | /> 24 | ); 25 | }; 26 | 27 | test('InputCustom', async () => { 28 | const app = createDomain(); 29 | 30 | const change = app.createEvent(); 31 | const $name = restore(change, ''); 32 | 33 | const Name = reflect({ 34 | view: InputCustom, 35 | bind: { value: $name, onChange: change }, 36 | }); 37 | 38 | const scope = fork(app); 39 | 40 | expect(scope.getState($name)).toBe(''); 41 | await allSettled(change, { scope, params: 'Bob' }); 42 | expect(scope.getState($name)).toBe('Bob'); 43 | 44 | const container = render( 45 | 46 | 47 | , 48 | ); 49 | 50 | const inputName = container.container.firstChild as HTMLInputElement; 51 | expect(inputName.value).toBe('Bob'); 52 | }); 53 | 54 | test('InputCustom [replace value]', async () => { 55 | const app = createDomain(); 56 | 57 | const change = app.createEvent(); 58 | const $name = app.createStore(''); 59 | 60 | $name.on(change, (_, next) => next); 61 | 62 | const Name = reflect({ 63 | view: InputCustom, 64 | bind: { name: $name, onChange: change }, 65 | }); 66 | 67 | const scope = fork(app); 68 | 69 | expect(scope.getState($name)).toBe(''); 70 | await allSettled(change, { scope, params: 'Bob' }); 71 | expect(scope.getState($name)).toBe('Bob'); 72 | 73 | const container = render( 74 | 75 | 76 | , 77 | ); 78 | 79 | const inputName = container.container.firstChild as HTMLInputElement; 80 | expect(inputName.value).toBe('Alise'); 81 | }); 82 | 83 | // Example 2 (InputBase) 84 | const InputBase: FC> = (props) => { 85 | return ; 86 | }; 87 | 88 | test('InputBase', async () => { 89 | const app = createDomain(); 90 | 91 | const changeName = app.createEvent(); 92 | const $name = restore(changeName, ''); 93 | 94 | const inputChanged = (event: ChangeEvent) => { 95 | return event.currentTarget.value; 96 | }; 97 | 98 | const Name = reflect({ 99 | view: InputBase, 100 | bind: { 101 | value: $name, 102 | onChange: changeName.prepend(inputChanged), 103 | }, 104 | }); 105 | 106 | const changeAge = app.createEvent(); 107 | const $age = restore(changeAge, 0); 108 | 109 | const Age = reflect({ 110 | view: InputBase, 111 | bind: { 112 | value: $age, 113 | onChange: changeAge.prepend(parseInt).prepend(inputChanged), 114 | }, 115 | }); 116 | 117 | const scope = fork(app); 118 | 119 | expect(scope.getState($name)).toBe(''); 120 | await allSettled(changeName, { scope, params: 'Bob' }); 121 | expect(scope.getState($name)).toBe('Bob'); 122 | 123 | expect(scope.getState($age)).toBe(0); 124 | await allSettled(changeAge, { scope, params: 25 }); 125 | expect(scope.getState($age)).toBe(25); 126 | 127 | const container = render( 128 | 129 | 130 | 131 | , 132 | ); 133 | 134 | const inputName = container.getByTestId('name') as HTMLInputElement; 135 | expect(inputName.value).toBe('Bob'); 136 | 137 | const inputAge = container.getByTestId('age') as HTMLInputElement; 138 | expect(inputAge.value).toBe('25'); 139 | }); 140 | 141 | test('with ssr for client', async () => { 142 | const app = createDomain(); 143 | 144 | const changeName = app.createEvent(); 145 | const $name = restore(changeName, ''); 146 | 147 | const Name = reflect({ 148 | view: (props: { 149 | value: string; 150 | onChange: (_event: ChangeEvent) => void; 151 | }) => { 152 | return ( 153 | 158 | ); 159 | }, 160 | bind: { 161 | value: $name, 162 | onChange: changeName.prepend((event) => event.currentTarget.value), 163 | }, 164 | }); 165 | 166 | const scope = fork(app); 167 | 168 | const container = render( 169 | 170 | 171 | , 172 | ); 173 | 174 | expect(scope.getState($name)).toBe(''); 175 | await userEvent.type(container.getByTestId('name'), 'Bob'); 176 | expect(scope.getState($name)).toBe('Bob'); 177 | 178 | const inputName = container.getByTestId('name') as HTMLInputElement; 179 | expect(inputName.value).toBe('Bob'); 180 | }); 181 | 182 | test('two scopes', async () => { 183 | const app = createDomain(); 184 | 185 | const changeName = app.createEvent(); 186 | const $name = restore(changeName, ''); 187 | 188 | const Name = reflect({ 189 | view: InputCustom, 190 | bind: { value: $name, onChange: changeName }, 191 | }); 192 | 193 | const scope1 = fork(app); 194 | const scope2 = fork(app); 195 | 196 | expect(scope2.getState($name)).toBe(''); 197 | await allSettled(changeName, { scope: scope2, params: 'Alise' }); 198 | expect(scope2.getState($name)).toBe('Alise'); 199 | 200 | expect(scope1.getState($name)).toBe(''); 201 | await allSettled(changeName, { scope: scope1, params: 'Bob' }); 202 | expect(scope1.getState($name)).toBe('Bob'); 203 | 204 | const container2 = render( 205 | 206 | 207 | , 208 | ); 209 | const container1 = render( 210 | 211 | 212 | , 213 | ); 214 | 215 | const inputName1 = container1.getByTestId('name1') as HTMLInputElement; 216 | const inputName2 = container2.getByTestId('name2') as HTMLInputElement; 217 | 218 | await allSettled(changeName, { scope: scope2, params: 'Alise' }); 219 | await allSettled(changeName, { scope: scope1, params: 'Bob' }); 220 | 221 | expect(scope1.getState($name)).toBe('Bob'); 222 | expect(scope2.getState($name)).toBe('Alise'); 223 | 224 | expect(inputName1.value).toBe('Bob'); 225 | expect(inputName2.value).toBe('Alise'); 226 | }); 227 | 228 | test('use only event for bind', async () => { 229 | const app = createDomain(); 230 | 231 | const changeName = app.createEvent(); 232 | const $name = restore(changeName, ''); 233 | 234 | const changeAge = app.createEvent(); 235 | 236 | const Name = reflect({ 237 | view: InputBase, 238 | bind: { 239 | value: $name, 240 | onChange: changeName.prepend((event) => event.currentTarget.value), 241 | }, 242 | }); 243 | 244 | const Age = reflect({ 245 | view: InputBase, 246 | bind: { 247 | onChange: changeAge.prepend((event) => 248 | Number.parseInt(event.currentTarget.value, 10), 249 | ), 250 | }, 251 | }); 252 | 253 | const scope = fork(app); 254 | 255 | const container = render( 256 | 257 | 258 | 259 | , 260 | ); 261 | 262 | const name = container.getByTestId('name') as HTMLInputElement; 263 | const age = container.getByTestId('age') as HTMLInputElement; 264 | 265 | expect(name.value).toBe(''); 266 | expect(age.value).toBe(''); 267 | }); 268 | -------------------------------------------------------------------------------- /src/no-ssr/reflect.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, InputHTMLAttributes, ChangeEvent } from 'react'; 2 | import { createStore, createEvent, restore, createEffect } from 'effector'; 3 | import { act } from 'react-dom/test-utils'; 4 | 5 | import { render } from '@testing-library/react'; 6 | import userEvent from '@testing-library/user-event'; 7 | 8 | import { reflect } from './index'; 9 | 10 | // Example1 (InputCustom) 11 | const InputCustom: FC<{ 12 | value: string | number | string[]; 13 | onChange(value: string): void; 14 | testId: string; 15 | placeholder?: string; 16 | }> = (props) => { 17 | return ( 18 | props.onChange(event.currentTarget.value)} 23 | /> 24 | ); 25 | }; 26 | 27 | test('InputCustom', async () => { 28 | const change = createEvent(); 29 | const $name = restore(change, ''); 30 | 31 | const Name = reflect({ 32 | view: InputCustom, 33 | bind: { value: $name, onChange: change }, 34 | }); 35 | 36 | const container = render(); 37 | 38 | expect($name.getState()).toBe(''); 39 | await userEvent.type(container.getByTestId('name'), 'Bob'); 40 | expect($name.getState()).toBe('Bob'); 41 | 42 | const inputName = container.container.firstChild as HTMLInputElement; 43 | expect(inputName.value).toBe('Bob'); 44 | }); 45 | 46 | test('InputCustom [replace value]', async () => { 47 | const change = createEvent(); 48 | const $name = createStore(''); 49 | 50 | $name.on(change, (_, next) => next); 51 | 52 | const Name = reflect({ 53 | view: InputCustom, 54 | bind: { name: $name, onChange: change }, 55 | }); 56 | 57 | const container = render(); 58 | 59 | expect($name.getState()).toBe(''); 60 | await userEvent.type(container.getByTestId('name'), 'Bob'); 61 | expect($name.getState()).toBe('Aliseb'); 62 | 63 | const inputName = container.container.firstChild as HTMLInputElement; 64 | expect(inputName.value).toBe('Alise'); 65 | }); 66 | 67 | // Example 2 (InputBase) 68 | const InputBase: FC> = (props) => { 69 | return ; 70 | }; 71 | 72 | test('InputBase', async () => { 73 | const changeName = createEvent(); 74 | const $name = restore(changeName, ''); 75 | 76 | const inputChanged = (event: ChangeEvent) => { 77 | return event.currentTarget.value; 78 | }; 79 | 80 | const Name = reflect({ 81 | view: InputBase, 82 | bind: { 83 | value: $name, 84 | onChange: changeName.prepend(inputChanged), 85 | }, 86 | }); 87 | 88 | const changeAge = createEvent(); 89 | const $age = restore(changeAge, 0); 90 | const Age = reflect({ 91 | view: InputBase, 92 | bind: { 93 | value: $age, 94 | onChange: changeAge.prepend(parseInt).prepend(inputChanged), 95 | }, 96 | }); 97 | 98 | const container = render( 99 | <> 100 | 101 | 102 | , 103 | ); 104 | 105 | expect($name.getState()).toBe(''); 106 | await userEvent.type(container.getByTestId('name'), 'Bob'); 107 | expect($name.getState()).toBe('Bob'); 108 | 109 | expect($age.getState()).toBe(0); 110 | await userEvent.type(container.getByTestId('age'), '25'); 111 | expect($age.getState()).toBe(25); 112 | 113 | const inputName = container.getByTestId('name') as HTMLInputElement; 114 | expect(inputName.value).toBe('Bob'); 115 | 116 | const inputAge = container.getByTestId('age') as HTMLInputElement; 117 | expect(inputAge.value).toBe('25'); 118 | }); 119 | 120 | test('component inside', async () => { 121 | const changeName = createEvent(); 122 | const $name = restore(changeName, ''); 123 | 124 | const Name = reflect({ 125 | view: (props: { 126 | value: string; 127 | onChange: (_event: ChangeEvent) => void; 128 | }) => { 129 | return ( 130 | 135 | ); 136 | }, 137 | bind: { 138 | value: $name, 139 | onChange: changeName.prepend((event) => event.currentTarget.value), 140 | }, 141 | }); 142 | 143 | const container = render(); 144 | 145 | expect($name.getState()).toBe(''); 146 | await userEvent.type(container.getByTestId('name'), 'Bob'); 147 | expect($name.getState()).toBe('Bob'); 148 | 149 | const inputName = container.getByTestId('name') as HTMLInputElement; 150 | expect(inputName.value).toBe('Bob'); 151 | }); 152 | 153 | describe('hooks', () => { 154 | describe('mounted', () => { 155 | test('callback', () => { 156 | const changeName = createEvent(); 157 | const $name = restore(changeName, ''); 158 | 159 | const mounted = jest.fn(() => {}); 160 | 161 | const Name = reflect({ 162 | view: InputBase, 163 | bind: { 164 | value: $name, 165 | onChange: changeName.prepend((event) => event.currentTarget.value), 166 | }, 167 | hooks: { mounted }, 168 | }); 169 | 170 | render(); 171 | 172 | expect(mounted.mock.calls.length).toBe(1); 173 | }); 174 | 175 | test('event', () => { 176 | const changeName = createEvent(); 177 | const $name = restore(changeName, ''); 178 | const mounted = createEvent(); 179 | 180 | const fn = jest.fn(() => {}); 181 | 182 | mounted.watch(fn); 183 | 184 | const Name = reflect({ 185 | view: InputBase, 186 | bind: { 187 | value: $name, 188 | onChange: changeName.prepend((event) => event.currentTarget.value), 189 | }, 190 | hooks: { mounted }, 191 | }); 192 | 193 | render(); 194 | 195 | expect(fn.mock.calls.length).toBe(1); 196 | }); 197 | }); 198 | 199 | describe('unmounted', () => { 200 | const changeVisible = createEffect({ handler: () => {} }); 201 | const $visible = restore( 202 | changeVisible.finally.map(({ params }) => params), 203 | true, 204 | ); 205 | 206 | const Branch = reflect<{ visible: boolean }>({ 207 | view: ({ visible, children }) => (visible ? <>{children} : null), 208 | bind: { visible: $visible }, 209 | }); 210 | 211 | beforeEach(() => { 212 | act(() => { 213 | changeVisible(true); 214 | }); 215 | }); 216 | 217 | test('callback', () => { 218 | const changeName = createEvent(); 219 | const $name = restore(changeName, ''); 220 | 221 | const unmounted = jest.fn(() => {}); 222 | 223 | const Name = reflect({ 224 | view: InputBase, 225 | bind: { 226 | value: $name, 227 | onChange: changeName.prepend((event) => event.currentTarget.value), 228 | }, 229 | hooks: { unmounted }, 230 | }); 231 | 232 | render(, { wrapper: Branch }); 233 | 234 | act(() => { 235 | changeVisible(false); 236 | }); 237 | 238 | expect(unmounted.mock.calls.length).toBe(1); 239 | }); 240 | 241 | test('event', () => { 242 | const changeName = createEvent(); 243 | const $name = restore(changeName, ''); 244 | 245 | const unmounted = createEvent(); 246 | const fn = jest.fn(() => {}); 247 | 248 | unmounted.watch(fn); 249 | 250 | const Name = reflect({ 251 | view: InputBase, 252 | bind: { 253 | value: $name, 254 | onChange: changeName.prepend((event) => event.currentTarget.value), 255 | }, 256 | hooks: { unmounted }, 257 | }); 258 | 259 | render(, { wrapper: Branch }); 260 | 261 | act(() => { 262 | changeVisible(false); 263 | }); 264 | 265 | expect(fn.mock.calls.length).toBe(1); 266 | }); 267 | }); 268 | }); 269 | --------------------------------------------------------------------------------