├── docs ├── pages │ ├── .gitignore │ ├── _app.mdx │ ├── docs │ │ ├── _meta.json │ │ ├── index.mdx │ │ ├── hooks.mdx │ │ ├── fromTag.mdx │ │ ├── create-reflect.mdx │ │ ├── variant.mdx │ │ ├── list.mdx │ │ └── reflect.mdx │ ├── _meta.json │ ├── learn │ │ ├── installation.mdx │ │ ├── testing.mdx │ │ └── motivation.mdx │ └── index.mdx ├── style.css ├── next-env.d.ts ├── postcss.config.js ├── tailwind.config.js ├── next.config.js ├── package.json ├── tsconfig.json └── theme.config.tsx ├── .lintstagedrc.json ├── .husky └── pre-commit ├── .prettierignore ├── type-tests ├── .eslintrc ├── tsconfig.json ├── types-create-reflect.tsx ├── types-from-tag.ts ├── types-list.tsx ├── types-variant.tsx └── types-reflect.tsx ├── babel.config.json ├── .eslintignore ├── src ├── core │ ├── index.ts │ ├── fromTag.ts │ ├── types.ts │ ├── variant.ts │ ├── list.ts │ └── reflect.ts ├── index.ts ├── scope.ts ├── ssr │ ├── create-reflect.test.tsx │ ├── variant.test.tsx │ ├── reflect.test.tsx │ └── list.test.tsx └── no-ssr │ ├── create-reflect.test.tsx │ ├── variant.test.tsx │ ├── list.test.tsx │ └── reflect.test.tsx ├── .gitignore ├── tsconfig.json ├── .eslintrc.js ├── .github ├── workflows │ ├── release-drafter.yml │ ├── test.yml │ ├── docs.yml │ └── publish.yml └── release-drafter.yml ├── .prettierrc ├── .config └── tsconfig.base.json ├── validate_dist.mjs ├── vitest.config.mts ├── rollup.config.cjs ├── Readme.md ├── package.json └── public-types └── reflect.d.ts /docs/pages/.gitignore: -------------------------------------------------------------------------------- 1 | !*.md 2 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.{js,mjs,cjs,ts,tsx}": ["prettier --write", "eslint --fix"] 3 | } 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | pnpm lint-staged 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist-test 2 | ./core 3 | node_modules 4 | dist 5 | .idea 6 | .vscode 7 | .husky 8 | .github 9 | -------------------------------------------------------------------------------- /type-tests/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "@typescript-eslint/consistent-type-definitions": "off" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-typescript", "@babel/preset-react"], 3 | "plugins": ["@babel/plugin-transform-runtime"] 4 | } 5 | -------------------------------------------------------------------------------- /docs/pages/_app.mdx: -------------------------------------------------------------------------------- 1 | import '../style.css' 2 | 3 | export default function App({ Component, pageProps }) { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | type-tests 2 | node_modules 3 | dist-test 4 | core 5 | .config 6 | .github 7 | /index.d.ts 8 | ./reflect.* 9 | ./scope.* 10 | ./ssr.* 11 | -------------------------------------------------------------------------------- /docs/style.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | @tailwind variants; 5 | 6 | body { 7 | font-feature-settings: 'rlig' 1, 'calt' 1; 8 | } 9 | -------------------------------------------------------------------------------- /src/core/index.ts: -------------------------------------------------------------------------------- 1 | export { reflectFactory, reflectCreateFactory } from './reflect'; 2 | export { variantFactory } from './variant'; 3 | export { listFactory } from './list'; 4 | export { fromTag } from './fromTag'; 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | .vscode 4 | 5 | yarn-error.log 6 | 7 | /dist 8 | /ssr/dist 9 | /typings 10 | /browser 11 | /no-ssr 12 | /ssr 13 | 14 | effector-reflect-* 15 | .idea 16 | docs/.next 17 | docs/out 18 | -------------------------------------------------------------------------------- /docs/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /docs/pages/docs/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "index": "Overview", 3 | "reflect": "reflect", 4 | "variant": "variant", 5 | "list": "list", 6 | "create-reflect": "createReflect", 7 | "hooks": "Reflect Hooks", 8 | "fromTag": "fromTag" 9 | } 10 | -------------------------------------------------------------------------------- /src/core/fromTag.ts: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react'; 2 | import { createElement } from 'react'; 3 | 4 | export function fromTag(htmlTag: HtmlTag) { 5 | return (props: Record): ReactNode => { 6 | return createElement(htmlTag, props); 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.config/tsconfig.base.json", 3 | "compilerOptions": { 4 | "baseUrl": ".", 5 | "paths": { 6 | "@effector/reflect": ["./public-types/reflect.d.ts"], 7 | "@effector/reflect/scope": ["./public-types/reflect.d.ts"] 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /docs/postcss.config.js: -------------------------------------------------------------------------------- 1 | // If you want to use other PostCSS plugins, see the following: 2 | // https://tailwindcss.com/docs/using-with-preprocessors 3 | /** @type {import('postcss').Postcss} */ 4 | module.exports = { 5 | plugins: { 6 | tailwindcss: {}, 7 | autoprefixer: {}, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const { configure, presets } = require('eslint-kit'); 2 | 3 | module.exports = configure({ 4 | root: __dirname, 5 | presets: [ 6 | presets.react(), 7 | // presets.effector(), 8 | presets.typescript(), 9 | presets.prettier(), 10 | presets.node(), 11 | ], 12 | }); 13 | -------------------------------------------------------------------------------- /docs/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | './pages/**/*.{js,ts,jsx,tsx,mdx}', 5 | './components/**/*.{js,ts,jsx,tsx}', 6 | './theme.config.tsx', 7 | ], 8 | theme: { 9 | extend: {}, 10 | }, 11 | plugins: [], 12 | darkMode: 'class', 13 | }; 14 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | workflow_dispatch: 8 | 9 | jobs: 10 | update_release_draft: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: release-drafter/release-drafter@v5 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | -------------------------------------------------------------------------------- /docs/next.config.js: -------------------------------------------------------------------------------- 1 | const nextra = require('nextra'); 2 | 3 | const withNextra = nextra({ 4 | theme: 'nextra-theme-docs', 5 | themeConfig: './theme.config.tsx', 6 | flexsearch: { 7 | codeblocks: false, 8 | }, 9 | defaultShowCopyCode: true, 10 | }); 11 | 12 | module.exports = withNextra({ 13 | output: 'export', 14 | images: { 15 | unoptimized: true, 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /docs/pages/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "index": { 3 | "title": "Home", 4 | "theme": { 5 | "breadcrumb": false, 6 | "footer": true, 7 | "sidebar": false, 8 | "toc": false, 9 | "pagination": false 10 | }, 11 | "display": "hidden" 12 | }, 13 | "learn": { 14 | "title": "Learn", 15 | "type": "page" 16 | }, 17 | "docs": { 18 | "type": "page", 19 | "title": "API" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /docs/pages/docs/index.mdx: -------------------------------------------------------------------------------- 1 | # API Docs 2 | 3 | import { Cards, Card } from "nextra-theme-docs"; 4 | 5 | Effector Reflect provides 3 main methods and a single factory: 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "printWidth": 86, 4 | "semi": true, 5 | "singleQuote": true, 6 | "tabWidth": 2, 7 | "trailingComma": "all", 8 | "useTabs": false, 9 | "plugins": ["@trivago/prettier-plugin-sort-imports"], 10 | "importOrder": ["^node:", "", "^[./]"], 11 | "importOrderSeparation": true, 12 | "importOrderSortSpecifiers": true, 13 | "importOrderCaseInsensitive": true 14 | } 15 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as context from 'effector-react'; 2 | 3 | import { 4 | fromTag, 5 | listFactory, 6 | reflectCreateFactory, 7 | reflectFactory, 8 | variantFactory, 9 | } from './core'; 10 | 11 | export const reflect = reflectFactory(context); 12 | export const createReflect = reflectCreateFactory(context); 13 | 14 | export const variant = variantFactory(context); 15 | 16 | export const list = listFactory(context); 17 | 18 | export { fromTag }; 19 | -------------------------------------------------------------------------------- /.config/tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 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 | "types": ["vitest/globals"], 12 | "strictNullChecks": true 13 | }, 14 | "include": ["../src"], 15 | "exclude": ["../typings"] 16 | } 17 | -------------------------------------------------------------------------------- /validate_dist.mjs: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | import { dirname, resolve } from 'node:path'; 3 | import { fileURLToPath } from 'node:url'; 4 | 5 | import 'zx/globals'; 6 | 7 | const __dirname = dirname(fileURLToPath(import.meta.url)); 8 | 9 | const pkgDir = resolve(__dirname, './dist'); 10 | try { 11 | const attwResult = await $`pnpm attw --pack ${pkgDir}`; 12 | } catch (error) {} 13 | console.log(); 14 | try { 15 | const publintResult = await $`pnpm publint ${pkgDir}`; 16 | } catch (error) {} 17 | -------------------------------------------------------------------------------- /src/scope.ts: -------------------------------------------------------------------------------- 1 | import * as effectorReactSSR from 'effector-react/scope'; 2 | 3 | import { 4 | listFactory, 5 | reflectCreateFactory, 6 | reflectFactory, 7 | variantFactory, 8 | } from './core'; 9 | 10 | console.error( 11 | '`@effector/reflect/scope` is deprecated, use main `@effector/reflect` package instead', 12 | ); 13 | 14 | export const reflect = reflectFactory(effectorReactSSR); 15 | export const createReflect = reflectCreateFactory(effectorReactSSR); 16 | 17 | export const variant = variantFactory(effectorReactSSR); 18 | 19 | export const list = listFactory(effectorReactSSR); 20 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@effector/reflect-docs", 3 | "private": true, 4 | "scripts": { 5 | "dev": "next dev", 6 | "build": "next build" 7 | }, 8 | "packageManager": "pnpm@7.27.0", 9 | "dependencies": { 10 | "next": "^13.4.5", 11 | "nextra": "^2.7.1", 12 | "nextra-theme-docs": "^2.7.1", 13 | "react": "^18.2.0", 14 | "react-dom": "^18.2.0" 15 | }, 16 | "devDependencies": { 17 | "@types/node": "20.3.1", 18 | "autoprefixer": "^10.4.14", 19 | "eslint": "^8.42.0", 20 | "postcss": "^8.4.24", 21 | "tailwindcss": "^3.3.2", 22 | "typescript": "^5.1.3" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": [ 4 | "dom", 5 | "dom.iterable", 6 | "esnext" 7 | ], 8 | "allowJs": true, 9 | "skipLibCheck": true, 10 | "strict": false, 11 | "forceConsistentCasingInFileNames": true, 12 | "noEmit": true, 13 | "incremental": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve" 20 | }, 21 | "include": [ 22 | "next-env.d.ts", 23 | "**/*.ts", 24 | "**/*.tsx" 25 | ], 26 | "exclude": [ 27 | "node_modules" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /type-tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "allowSyntheticDefaultImports": true, 5 | "allowUnreachableCode": false, 6 | "module": "esnext", 7 | "moduleResolution": "Node", 8 | "noEmit": true, 9 | "noFallthroughCasesInSwitch": true, 10 | "noImplicitAny": true, 11 | "noImplicitReturns": true, 12 | "noImplicitThis": true, 13 | "noUnusedLocals": false, 14 | "strict": true, 15 | "skipLibCheck": true, 16 | "jsx": "react", 17 | "strictNullChecks": true, 18 | "paths": { 19 | "@effector/reflect": ["../public-types/reflect.d.ts"], 20 | "@effector/reflect/scope": ["../public-types/reflect.d.ts"] 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test Package 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test-package: 7 | runs-on: ubuntu-22.04 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v3 11 | 12 | - name: Setup pnpm 13 | uses: pnpm/action-setup@v2 14 | 15 | - name: Use Node.js 18.x 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: 18.x 19 | cache: 'pnpm' 20 | 21 | - name: Install dependencies 22 | run: pnpm install --frozen-lockfile 23 | 24 | - name: Build 25 | run: pnpm build 26 | 27 | - name: Run tests 28 | run: pnpm test 29 | env: 30 | CI: true 31 | - name: Validate package 32 | run: pnpm validate:dist 33 | -------------------------------------------------------------------------------- /vitest.config.mts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react'; 2 | import { dirname, resolve } from 'path'; 3 | import { fileURLToPath } from 'url'; 4 | import { defineConfig } from 'vitest/config'; 5 | import { $ } from 'zx'; 6 | 7 | if (!process.env.CI) { 8 | /** 9 | * Vitest tests are always run against the built package. 10 | */ 11 | console.log('Building the package...'); 12 | await $`pnpm build`; 13 | $.log = () => {}; 14 | } 15 | 16 | const __dirname = dirname(fileURLToPath(import.meta.url)); 17 | 18 | export default defineConfig({ 19 | test: { 20 | globals: true, 21 | environment: 'jsdom', 22 | }, 23 | plugins: [react()], 24 | resolve: { 25 | alias: { 26 | '@effector/reflect/scope': resolve(__dirname, './dist/scope.mjs'), 27 | '@effector/reflect': resolve(__dirname, './dist/index.mjs'), 28 | }, 29 | }, 30 | }); 31 | -------------------------------------------------------------------------------- /docs/pages/learn/installation.mdx: -------------------------------------------------------------------------------- 1 | # Welcome to @effector/reflect 2 | 3 | ☄️ Attach effector stores to react components without hooks. 4 | 5 | ```sh 6 | npm install @effector/reflect 7 | # or 8 | pnpm add @effector/reflect 9 | ``` 10 | 11 | ## Usage 12 | 13 | ```ts 14 | import { reflect } from '@effector/reflect' 15 | import { TextInput } from '@ui/lib'; 16 | 17 | import { $email, emailChanged, $formDisabled } from './model' 18 | 19 | // Let's define bound component 20 | const EmailField = reflect({ 21 | view: TextInput, 22 | bind: { 23 | value: $email, 24 | onChange: emailChanged, 25 | disabled: $formDisabled, 26 | }, 27 | }) 28 | ``` 29 | 30 | Now you can safely use it as common React-component: 31 | 32 | ```tsx 33 | export const LoginForm: React.FC = () => { 34 | return ( 35 |
36 | 37 | 38 | ) 39 | } 40 | ``` 41 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | categories: 2 | - title: '⚠️ Breaking changes' 3 | label: 'BREAKING CHANGES' 4 | 5 | - title: '🚀 Features' 6 | labels: 7 | - 'feature' 8 | - 'enhancement' 9 | 10 | - title: '🐛 Bug Fixes' 11 | labels: 12 | - 'fix' 13 | - 'bugfix' 14 | - 'bug' 15 | 16 | - title: '🧰 Maintenance' 17 | labels: 18 | - 'chore' 19 | - 'dependencies' 20 | 21 | - title: '📚 Documentation' 22 | label: 'documentation' 23 | 24 | - title: '🧪 Tests' 25 | label: 'tests' 26 | 27 | - title: '🏎 Optimizations' 28 | label: 'optimizations' 29 | 30 | version-resolver: 31 | major: 32 | labels: 33 | - 'BREAKING CHANGES' 34 | minor: 35 | labels: 36 | - 'feature' 37 | - 'enhancement' 38 | patch: 39 | labels: 40 | - 'fix' 41 | default: patch 42 | 43 | name-template: 'v$RESOLVED_VERSION' 44 | tag-template: 'v$RESOLVED_VERSION' 45 | 46 | change-template: '- $TITLE #$NUMBER (@$AUTHOR)' 47 | template: | 48 | $CHANGES 49 | -------------------------------------------------------------------------------- /docs/theme.config.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/react-in-jsx-scope */ 2 | import { useRouter } from 'next/router'; 3 | import { type DocsThemeConfig } from 'nextra-theme-docs'; 4 | 5 | const config: DocsThemeConfig = { 6 | logo: @effector/reflect, 7 | project: { 8 | link: 'https://github.com/effector/reflect', 9 | }, 10 | docsRepositoryBase: 'https://github.com/effector/reflect/blob/master/docs', 11 | darkMode: true, 12 | primaryHue: 35, 13 | navbar: { 14 | // extraContent: <>Link to Effector Docs, 15 | }, 16 | footer: { 17 | text: ( 18 | 19 | MIT {new Date().getFullYear()} ©{' '} 20 | 21 | Effector Core Team 22 | 23 | 24 | ), 25 | }, 26 | faviconGlyph: '☄️', 27 | useNextSeoProps() { 28 | const { asPath } = useRouter(); 29 | if (asPath !== '/') { 30 | return { 31 | titleTemplate: '%s | @effector/reflect', 32 | }; 33 | } 34 | }, 35 | }; 36 | 37 | export default config; 38 | -------------------------------------------------------------------------------- /src/core/types.ts: -------------------------------------------------------------------------------- 1 | import { Effect, EventCallable, Store } from 'effector'; 2 | import { useList, useUnit } from 'effector-react'; 3 | import { ComponentType } from 'react'; 4 | 5 | /** 6 | * This is the internal typings - for the library internals, where we do not really care about real-world user data. 7 | * 8 | * Public types are stored separately from the source code, so it is easier to develop and test them. 9 | * You can find public types in `public-types` folder and tests for type inference at the `type-tests` folder. 10 | */ 11 | 12 | export interface Context { 13 | useUnit: typeof useUnit; 14 | useList: typeof useList; 15 | } 16 | 17 | export type View = ComponentType; 18 | 19 | export type BindProps = { 20 | [K in keyof Props]: Props[K] | Store | EventCallable; 21 | }; 22 | 23 | export type Hook = 24 | | ((props: Props) => any) 25 | | EventCallable 26 | | Effect; 27 | 28 | export type Hooks = { 29 | mounted?: Hook; 30 | unmounted?: Hook; 31 | }; 32 | 33 | export type UseUnitConifg = Parameters[1]; 34 | -------------------------------------------------------------------------------- /docs/pages/docs/hooks.mdx: -------------------------------------------------------------------------------- 1 | 2 | # Reflect Hooks 3 | 4 | Hooks is an object passed to `reflect()`, or `variant()`, etc. with properties `mounted` and `unmounted` all optional. 5 | 6 | Both `mounted` and `unmounted` can be either function or Effector's Event. Reflect will call provided function with rendered props of the component. 7 | 8 | ## Example 9 | 10 | ```tsx 11 | import { reflect, variant } from '@effector/reflect'; 12 | import { Range, TextInput } from '@org/my-ui'; 13 | import { createEvent, createStore } from 'effector'; 14 | 15 | const $type = createStore<'text' | 'range'>('text'); 16 | const $value = createStore(''); 17 | const valueChange = createEvent(); 18 | const rangeMounted = createEvent(); 19 | const fieldMounted = createEvent(); 20 | 21 | const RangePrimary = reflect({ 22 | view: Range, 23 | bind: { style: 'primary' }, 24 | hooks: { mounted: rangeMounted }, 25 | }); 26 | 27 | const Field = variant({ 28 | source: $type, 29 | bind: { value: $value, onChange: valueChange }, 30 | cases: { 31 | text: TextInput, 32 | range: RangePrimary, 33 | }, 34 | hooks: { mounted: fieldMounted }, 35 | }); 36 | ``` 37 | 38 | When `Field` is mounted, `fieldMounted` and `rangeMounted` would be called. 39 | -------------------------------------------------------------------------------- /type-tests/types-create-reflect.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import { createReflect } from '@effector/reflect'; 3 | import { Button } from '@mantine/core'; 4 | import { createEvent, createStore } from 'effector'; 5 | import React from 'react'; 6 | import { expectType } from 'tsd'; 7 | 8 | // basic createReflect 9 | { 10 | const Input: React.FC<{ 11 | value: string; 12 | onChange: (newValue: string) => void; 13 | color: 'red'; 14 | }> = () => null; 15 | const $value = createStore(''); 16 | const changed = createEvent(); 17 | 18 | const reflectInput = createReflect(Input); 19 | 20 | const ReflectedInput = reflectInput({ 21 | value: $value, 22 | onChange: changed, 23 | color: 'red', 24 | }); 25 | 26 | expectType(ReflectedInput); 27 | } 28 | 29 | // Edge-case: Mantine Button with weird polymorphic factory 30 | { 31 | const clicked = createEvent(); 32 | 33 | const reflectButton = createReflect(Button<'button'>); 34 | 35 | const ReflectedButton = reflectButton({ 36 | children: 'Click me', 37 | color: 'red', 38 | onClick: (e) => clicked(e.clientX), 39 | }); 40 | 41 | ; 42 | } 43 | -------------------------------------------------------------------------------- /docs/pages/docs/fromTag.mdx: -------------------------------------------------------------------------------- 1 | # `fromTag` 2 | 3 | ```ts 4 | import { fromTag } from '@effector/reflect'; 5 | ``` 6 | 7 | ```ts 8 | const DomInput = fromTag('input'); 9 | ``` 10 | 11 | **This feature is available since `9.0.0` release of Reflect.** 12 | 13 | Helper to simplify `reflect` usage with pure DOM elements by creating simple components based on a html tag. 14 | Such cases can happen, when project uses tools like Tailwind. 15 | 16 | ## Arguments 17 | 18 | 1. `htmlTag` - any valid and React-supported html tag. 19 | 20 | ## Returns 21 | 22 | - React Component, which renders dom element. 23 | 24 | ## Example 25 | 26 | Tailwind users sometimes don't create components, but simply compose classes to apply to regular HTML elements. 27 | 28 | ```ts 29 | import { fromTag, reflect } from '@effector/reflect'; 30 | 31 | const DomInput = fromTag('input'); 32 | 33 | const inputEl = cva('px-3 py-2', { 34 | variants: { 35 | size: { 36 | base: 'text-base', 37 | large: 'text-3xl', 38 | }, 39 | }, 40 | }); 41 | 42 | const NameField = reflect({ 43 | view: DomInput, 44 | bind: { 45 | type: 'email', 46 | value: $value, 47 | className: inputEl({ size: $inputSize }), 48 | }, 49 | }); 50 | ``` 51 | 52 | ### Type inference caveat 53 | 54 | For some reason Typescript type inference works a bit worse, when `fromTag` call is inlined into `reflect` - specifically, callback types are not properly inferred. 55 | Typescript still will check it properly, if you type your callback manually though. 🤷‍♂️ 56 | 57 | It is recommended to save `fromTag` call result into separate variable instead, since for some reason it works better with TS. 58 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy docs to Pages 2 | 3 | on: 4 | push: 5 | branches: ["master"] 6 | workflow_dispatch: 7 | 8 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | 14 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 15 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 16 | concurrency: 17 | group: "pages" 18 | cancel-in-progress: false 19 | 20 | jobs: 21 | deploy: 22 | environment: 23 | name: github-pages 24 | url: ${{ steps.deployment.outputs.page_url }} 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v3 29 | 30 | - name: Setup pnpm 31 | uses: pnpm/action-setup@v2 32 | 33 | - name: Use Node.js 18.x 34 | uses: actions/setup-node@v3 35 | with: 36 | node-version: 18.x 37 | cache: 'pnpm' 38 | cache-dependency-path: '**/pnpm-lock.yaml' 39 | 40 | - name: Setup parent deps 41 | run: 'pnpm install' 42 | 43 | - name: Setup deps 44 | run: 'pnpm install' 45 | working-directory: 'docs/' 46 | 47 | - name: Build 48 | run: 'pnpm build' 49 | working-directory: 'docs/' 50 | 51 | - name: Setup Pages 52 | uses: actions/configure-pages@v3 53 | 54 | - name: Upload artifact 55 | uses: actions/upload-pages-artifact@v3 56 | with: 57 | path: 'docs/out/' 58 | 59 | - name: Deploy to GitHub Pages 60 | id: deployment 61 | uses: actions/deploy-pages@v4 62 | -------------------------------------------------------------------------------- /docs/pages/docs/create-reflect.mdx: -------------------------------------------------------------------------------- 1 | 2 | # `createReflect` 3 | 4 | ```ts 5 | import {createReflect} from '@effector/reflect' 6 | ``` 7 | 8 | Method for creating reflect a view. So you can create a UI kit by views and use a view with a store already. 9 | 10 | ```tsx 11 | // ./ui.tsx 12 | import { createReflect } from '@effector/reflect'; 13 | import React, { ChangeEvent, FC, MouseEvent, useCallback } from 'react'; 14 | 15 | // Input 16 | type InputProps = { 17 | value: string; 18 | onChange: ChangeEvent; 19 | }; 20 | 21 | const Input: FC = ({ value, onChange }) => { 22 | return ; 23 | }; 24 | 25 | export const reflectInput = createReflect(Input); 26 | 27 | // Button 28 | type ButtonProps = { 29 | onClick: MouseEvent; 30 | title?: string; 31 | }; 32 | 33 | const Button: FC = ({ onClick, children, title }) => { 34 | return ( 35 | 38 | ); 39 | }; 40 | 41 | export const reflectButton = createReflect(Button); 42 | ``` 43 | 44 | ```tsx 45 | // ./user.tsx 46 | import { createEvent, restore } from 'effector'; 47 | import React, { FC } from 'react'; 48 | 49 | import { reflectButton, reflectInput } from './ui'; 50 | 51 | // Model 52 | const changeName = createEvent(); 53 | const $name = restore(changeName, ''); 54 | 55 | const changeAge = createEvent(); 56 | const $age = restore(changeAge, 0); 57 | 58 | const submit = createEvent(); 59 | 60 | // Components 61 | const Name = reflectInput({ 62 | value: $name, 63 | onChange: (event) => changeName(event.target.value), 64 | }); 65 | 66 | const Age = reflectInput({ 67 | value: $age, 68 | onChange: (event) => changeAge(parsetInt(event.target.value)), 69 | }); 70 | 71 | const Submit = reflectButton({ 72 | onClick: submit, 73 | }); 74 | 75 | export const User: FC = () => { 76 | return ( 77 |
78 | 79 | 80 | Save left 81 | Save right 82 |
83 | ); 84 | }; 85 | ``` 86 | -------------------------------------------------------------------------------- /rollup.config.cjs: -------------------------------------------------------------------------------- 1 | const typescript = require('rollup-plugin-typescript2'); 2 | const babel = require('@rollup/plugin-babel'); 3 | const terser = require('@rollup/plugin-terser'); 4 | const { nodeResolve } = require('@rollup/plugin-node-resolve'); 5 | const commonjs = require('@rollup/plugin-commonjs'); 6 | 7 | const babelConfig = require('./babel.config.json'); 8 | 9 | const plugins = () => [ 10 | typescript({ 11 | tsconfig: './tsconfig.json', 12 | check: false, 13 | }), 14 | babel({ 15 | exclude: 'node_modules/**', 16 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 17 | babelHelpers: 'runtime', 18 | presets: babelConfig.presets, 19 | plugins: babelConfig.plugins, 20 | }), 21 | nodeResolve({ 22 | jsnext: true, 23 | skip: ['effector'], 24 | extensions: ['.js', '.mjs'], 25 | }), 26 | commonjs({ extensions: ['.js', '.mjs'] }), 27 | // terser(), 28 | ]; 29 | 30 | const noSsr = './src/index.ts'; 31 | const scope = './src/scope.ts'; 32 | const external = ['effector', 'effector-react', 'react', 'effector-react/scope']; 33 | 34 | module.exports = [ 35 | { 36 | input: noSsr, 37 | external, 38 | plugins: plugins(), 39 | output: { 40 | file: './dist/index.mjs', 41 | format: 'es', 42 | sourcemap: true, 43 | externalLiveBindings: false, 44 | }, 45 | }, 46 | { 47 | input: scope, 48 | external, 49 | plugins: plugins(), 50 | output: { 51 | file: './dist/scope.mjs', 52 | format: 'es', 53 | sourcemap: true, 54 | externalLiveBindings: false, 55 | }, 56 | }, 57 | { 58 | input: noSsr, 59 | external, 60 | plugins: plugins(), 61 | output: { 62 | file: './dist/index.cjs', 63 | format: 'cjs', 64 | freeze: false, 65 | exports: 'named', 66 | sourcemap: true, 67 | externalLiveBindings: false, 68 | }, 69 | }, 70 | { 71 | input: scope, 72 | external, 73 | plugins: plugins(), 74 | output: { 75 | file: './dist/scope.js', 76 | format: 'cjs', 77 | freeze: false, 78 | exports: 'named', 79 | sourcemap: true, 80 | externalLiveBindings: false, 81 | }, 82 | }, 83 | ]; 84 | 85 | // pnpm why sourcemap-codec 86 | // pnpm why sane 87 | // w3c-hr-time 88 | // source-map-resolve 89 | // resolve-url 90 | // source-map-url 91 | // urix 92 | -------------------------------------------------------------------------------- /type-tests/types-from-tag.ts: -------------------------------------------------------------------------------- 1 | import { fromTag, reflect } from '@effector/reflect'; 2 | import { createEvent, createStore } from 'effector'; 3 | import React from 'react'; 4 | import { expectType } from 'tsd'; 5 | 6 | // fromTag creates a valid component 7 | { 8 | const Input = fromTag('input'); 9 | 10 | expectType< 11 | ( 12 | props: React.PropsWithChildren< 13 | React.ClassAttributes & 14 | React.InputHTMLAttributes 15 | >, 16 | ) => React.ReactNode 17 | >(Input); 18 | } 19 | 20 | // fromTag compoment is allowed in reflect 21 | { 22 | const Input = fromTag('input'); 23 | 24 | const $value = createStore(''); 25 | 26 | const View = reflect({ 27 | view: Input, 28 | bind: { 29 | value: $value, 30 | onChange: (e) => { 31 | const strValue = e.target.value; 32 | 33 | strValue.trim(); 34 | }, 35 | }, 36 | }); 37 | } 38 | 39 | // inline fromTag is supported 40 | { 41 | const $value = createStore(''); 42 | 43 | const handleChange = createEvent(); 44 | 45 | const View = reflect({ 46 | view: fromTag('input'), 47 | bind: { 48 | type: 'text', 49 | value: $value, 50 | /** 51 | * Type inference for inline fromTag is slightly worse, than for non-inline version :( 52 | * 53 | * I don't known why, but in this case `onChange` argument type must be type explicitly, 54 | * type inference doesn't work here 55 | * 56 | * TypeScript won't allow invalid value, 57 | * but also won't infer correct type for us here, like it does with non-inline usage :shrug: 58 | */ 59 | onChange: (e: React.ChangeEvent) => { 60 | handleChange(e.target.value); 61 | }, 62 | }, 63 | }); 64 | } 65 | 66 | // invalid props are not supported 67 | { 68 | const Input = fromTag('input'); 69 | 70 | const $value = createStore({}); 71 | 72 | const View = reflect({ 73 | view: Input, 74 | bind: { 75 | // @ts-expect-error 76 | value: $value, 77 | // @ts-expect-error 78 | onChange: (e: string) => {}, 79 | }, 80 | }); 81 | 82 | const View2 = reflect({ 83 | view: fromTag('input'), 84 | bind: { 85 | // @ts-expect-error 86 | value: $value, 87 | // @ts-expect-error 88 | onChange: 'kek', 89 | }, 90 | }); 91 | } 92 | -------------------------------------------------------------------------------- /docs/pages/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Welcome to @effector/reflect" 3 | --- 4 | 5 | import Link from 'next/link' 6 | 7 |
8 |
9 |

@effector/reflect

10 |

Easily render React components
with everything you love from effector.

11 |

12 | Get started 13 |

14 |
15 | 63 |
64 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish CI 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish-to-npm: 9 | runs-on: ubuntu-22.04 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v3 13 | 14 | - name: Setup pnpm 15 | uses: pnpm/action-setup@v2 16 | 17 | - name: Use Node.js 18.x 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: 18.x 21 | cache: 'pnpm' 22 | 23 | - name: Install dependencies 24 | run: pnpm install --frozen-lockfile 25 | 26 | - name: Build 27 | run: pnpm build 28 | 29 | - name: Run tests 30 | run: pnpm test 31 | env: 32 | CI: true 33 | 34 | - name: Extract version 35 | id: version 36 | uses: olegtarasov/get-tag@v2.1 37 | with: 38 | tagRegex: 'v(.*)' 39 | 40 | - name: Set version from release 41 | uses: reedyuk/npm-version@1.1.1 42 | with: 43 | version: ${{ steps.version.outputs.tag }} 44 | package: 'dist/' 45 | git-tag-version: false 46 | 47 | - name: Create NPM config 48 | run: npm config set //registry.npmjs.org/:_authToken $NPM_TOKEN 49 | env: 50 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 51 | 52 | - name: Check for NEXT tag 53 | id: next 54 | uses: actions-ecosystem/action-regex-match@v2 55 | with: 56 | text: ${{ steps.version.outputs.tag }} 57 | regex: '-next' 58 | 59 | - name: Check for RC tag 60 | id: rc 61 | uses: actions-ecosystem/action-regex-match@v2 62 | with: 63 | text: ${{ steps.version.outputs.tag }} 64 | regex: '-rc' 65 | 66 | - name: Publish @effector/reflect@${{ steps.version.outputs.tag }} with NEXT tag 67 | if: ${{ steps.next.outputs.match != '' }} 68 | working-directory: './dist/' 69 | run: npm publish --tag next 70 | 71 | - name: Publish @effector/reflect@${{ steps.version.outputs.tag }} with RC tag 72 | if: ${{ steps.rc.outputs.match != '' }} 73 | working-directory: './dist/' 74 | run: npm publish --tag rc 75 | 76 | - name: Publish @effector/reflect@${{ steps.version.outputs.tag }} to latest 77 | if: ${{ steps.next.outputs.match == '' && steps.rc.outputs.match == '' }} 78 | working-directory: './dist/' 79 | run: npm publish 80 | -------------------------------------------------------------------------------- /src/core/variant.ts: -------------------------------------------------------------------------------- 1 | import { Store } from 'effector'; 2 | import React from 'react'; 3 | 4 | import { reflectFactory } from './reflect'; 5 | import { BindProps, Context, Hooks, UseUnitConifg, View } from './types'; 6 | 7 | const Default = () => null; 8 | 9 | export function variantFactory(context: Context) { 10 | const reflect = reflectFactory(context); 11 | 12 | return function variant< 13 | Props, 14 | Variant extends string, 15 | Bind extends BindProps, 16 | >( 17 | config: 18 | | { 19 | source: Store; 20 | bind?: Bind; 21 | cases: Record>; 22 | hooks?: Hooks; 23 | default?: View; 24 | useUnitConfig?: UseUnitConifg; 25 | } 26 | | { 27 | if: Store; 28 | then: View; 29 | else?: View; 30 | hooks?: Hooks; 31 | bind?: Bind; 32 | useUnitConfig?: UseUnitConifg; 33 | }, 34 | ): (p: Props) => React.ReactNode { 35 | let $case: Store; 36 | let cases: Record>; 37 | let def: View; 38 | 39 | // Shortcut for Store 40 | if ('if' in config) { 41 | $case = config.if; 42 | cases = { 43 | then: config.then, 44 | else: config.else, 45 | } as unknown as Record>; 46 | def = Default; 47 | } 48 | // Full form for Store 49 | else { 50 | $case = config.source; 51 | cases = config.cases; 52 | def = config.default ?? Default; 53 | } 54 | 55 | function View(props: Props) { 56 | const nameOfCaseRaw = context.useUnit($case, config.useUnitConfig); 57 | const nameOfCase = ( 58 | typeof nameOfCaseRaw === 'string' 59 | ? nameOfCaseRaw 60 | : booleanToVariant(nameOfCaseRaw) 61 | ) as Variant; 62 | const Component = cases[nameOfCase] ?? def; 63 | 64 | return React.createElement(Component as any, props as any); 65 | } 66 | 67 | const bind = config.bind ?? ({} as Bind); 68 | 69 | return reflect({ 70 | bind, 71 | view: View, 72 | hooks: config.hooks, 73 | useUnitConfig: config.useUnitConfig, 74 | }) as unknown as (p: Props) => React.ReactNode; 75 | }; 76 | } 77 | 78 | function booleanToVariant(value: boolean): 'then' | 'else' { 79 | return value ? 'then' : 'else'; 80 | } 81 | -------------------------------------------------------------------------------- /docs/pages/docs/variant.mdx: -------------------------------------------------------------------------------- 1 | # `variant` 2 | 3 | ```ts 4 | import { variant } from '@effector/reflect'; 5 | ``` 6 | 7 | ```tsx 8 | const Components = variant({ 9 | source: $typeSelector, 10 | bind: Props, 11 | cases: ComponentVariants, 12 | default: DefaultVariant, 13 | hooks: Hooks, 14 | }); 15 | ``` 16 | 17 | Method allows to change component based on value in `$typeSelector`. Optional `bind` allow to pass props bound to stores or events. 18 | 19 | ## Arguments 20 | 21 | 1. `source` — Store of `string` value. Used to select variant of component to render and bound props to. 22 | 1. `bind` — Optional object of stores, events, and static values that would be bound as props. 23 | 1. `cases` — Object of components, key will be used to match 24 | 1. `default` — Optional component, that would be used if no matched in `cases` 25 | 1. [`hooks`](/docs/hooks) — Optional object `{ mounted, unmounted }` to handle when component is mounted or unmounted. 26 | 1. `useUnitConfig` - Optional configuration object, which is passed directly to the second argument of `useUnit` from `effector-react`. 27 | 28 | ## Example 29 | 30 | When `Field` is rendered it checks for `$fieldType` value, selects the appropriate component from `cases` and bound props to it. 31 | 32 | ```tsx 33 | import { variant } from '@effector/reflect'; 34 | import { DateSelector, Range, TextInput } from '@org/ui-lib'; 35 | import { createEvent, createStore } from 'effector'; 36 | import React from 'react'; 37 | 38 | const $fieldType = createStore<'date' | 'number' | 'string'>('string'); 39 | 40 | const valueChanged = createEvent(); 41 | const $value = createStore(''); 42 | 43 | const Field = variant({ 44 | source: $fieldType, 45 | bind: { onChange: valueChanged, value: $value }, 46 | cases: { 47 | date: DateSelector, 48 | number: Range, 49 | }, 50 | default: TextInput, 51 | }); 52 | ``` 53 | 54 | ## Shorthand for boolean cases 55 | 56 | When you have only two cases, you can use `variant` shorthand. 57 | 58 | ```tsx 59 | const Component = variant({ 60 | if: $isError, 61 | then: ErrorComponent, 62 | else: SuccessComponent, 63 | }); 64 | ``` 65 | 66 | This is equivalent to 67 | 68 | ```tsx 69 | const Component = variant({ 70 | source: $isError.map((isError) => (isError ? 'error' : 'success')), 71 | cases: { 72 | error: ErrorComponent, 73 | success: SuccessComponent, 74 | }, 75 | }); 76 | ``` 77 | 78 | This shorthand supports `bind` and `hooks` fields as well. 79 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # @effector/reflect 2 | 3 | ☄️ Attach effector stores to react components without hooks. 4 | 5 | ## Install 6 | 7 | ```sh 8 | npm install @effector/reflect 9 | # or 10 | pnpm add @effector/reflect 11 | ``` 12 | 13 | [Getting started](https://reflect.effector.dev/learn/installation) | [API Docs](https://reflect.effector.dev/docs) 14 | 15 | ## Motivation 16 | 17 | What's the point of reflect? 18 | 19 | It's the API design that, using the classic HOC-like pattern, allows you to write React components with Effector in an efficient and composable way. 20 | 21 | ```tsx 22 | import { reflect, variant } from '@effector/reflect'; 23 | 24 | export function UserForm() { 25 | return ( 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | } 33 | 34 | const Name = reflect({ 35 | view: Input, 36 | bind: { 37 | value: model.$name, 38 | onChange: model.nameChanged, 39 | }, 40 | }); 41 | 42 | const LastName = reflect({ 43 | view: Input, 44 | bind: { 45 | value: model.$lastName, 46 | onChange: model.lastNameChanged, 47 | }, 48 | }); 49 | 50 | const SubmitButton = reflect({ 51 | view: Button, 52 | bind: { 53 | type: 'submit', // plain values are allowed too! 54 | disabled: model.$formValid.map((valid) => !valid), 55 | onClick: model.formSubmitted, 56 | }, 57 | }); 58 | ``` 59 | 60 | Here we've separated this component into small parts, which were created in a convenient way using `reflect` operators, which is a very simple description of the `props -> values` mapping, which is easier to read and modify. 61 | 62 | With `@effector/reflect`, our `$formValid` update will only cause the SubmitButton to be re-rendered, and for all other parts of our `` there will literally be **zero** React work. 63 | 64 | To learn more, please read the [full Motivation article](https://reflect.effector.dev/learn/motivation). 65 | 66 | ## Release process 67 | 68 | 1. Check out the [draft release](https://github.com/effector/reflect/releases). 69 | 2. All PRs should have correct labels and useful titles. You can [review available labels here](https://github.com/effector/reflect/blob/master/.github/release-drafter.yml). 70 | 3. Update labels for PRs and titles, next [manually run the release drafter action](https://github.com/effector/reflect/actions/workflows/release-drafter.yml) to regenerate the draft release. 71 | 4. Review the new version and press "Publish" 72 | 5. If required check "Create discussion for this release" 73 | -------------------------------------------------------------------------------- /src/core/list.ts: -------------------------------------------------------------------------------- 1 | import { scopeBind, Store } from 'effector'; 2 | import { useProvidedScope } from 'effector-react'; 3 | import React from 'react'; 4 | 5 | import { reflectFactory } from './reflect'; 6 | import { BindProps, Context, Hooks, UseUnitConifg, View } from './types'; 7 | 8 | export function listFactory(context: Context) { 9 | const reflect = reflectFactory(context); 10 | 11 | return function list< 12 | Item extends Record, 13 | Props, 14 | Bind extends BindProps, 15 | >(config: { 16 | source: Store; 17 | view: View; 18 | bind?: Bind; 19 | mapItem?: { 20 | [K in keyof Props]: (item: Item, index: number) => Props[K]; 21 | }; 22 | getKey?: (item: Item) => React.Key; 23 | hooks?: Hooks; 24 | useUnitConfig?: UseUnitConifg; 25 | }): React.FC { 26 | const ItemView = reflect({ 27 | view: config.view, 28 | bind: config.bind ? config.bind : ({} as Bind), 29 | hooks: config.hooks, 30 | useUnitConfig: config.useUnitConfig, 31 | }); 32 | 33 | const listConfig = { 34 | getKey: config.getKey, 35 | fn: (value: Item, index: number) => { 36 | const scope = useProvidedScope(); 37 | const finalProps = React.useMemo(() => { 38 | const props: any = {}; 39 | 40 | if (config.mapItem) { 41 | forIn(config.mapItem, (prop) => { 42 | const fn = 43 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 44 | config.mapItem![prop]; 45 | const propValue = fn(value, index); 46 | 47 | if (typeof propValue === 'function') { 48 | props[prop] = scopeBind(propValue, { 49 | safe: true, 50 | scope: scope || undefined, 51 | }); 52 | } else { 53 | props[prop] = propValue; 54 | } 55 | }); 56 | } else { 57 | forIn(value, (prop) => { 58 | props[prop] = value[prop]; 59 | }); 60 | } 61 | 62 | return props; 63 | }, [value, index]); 64 | 65 | return React.createElement(ItemView, finalProps); 66 | }, 67 | }; 68 | 69 | return () => context.useList(config.source, listConfig); 70 | }; 71 | } 72 | 73 | function forIn, R extends any>( 74 | target: T, 75 | fn: (_t: keyof T) => R, 76 | ): void { 77 | const hasProp = {}.hasOwnProperty; 78 | for (const prop in target) { 79 | if (hasProp.call(target, prop)) fn(prop); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /docs/pages/learn/testing.mdx: -------------------------------------------------------------------------------- 1 | # SSR and tests via Fork API 2 | 3 | Since [effector-react 22.5.0](https://github.com/effector/effector/releases/tag/effector-react%4022.5.0) it is no longer necessary to use `@effector/reflect/ssr` due to isomorphic nature of `effector-react` hooks after this release, you can just use `@effector/reflect` main imports. 4 | 5 | Just add `Provider` from `effector-react` to your app root, and you are good to go. 6 | 7 | ```tsx 8 | // ./ui.tsx 9 | import React, { ChangeEvent, FC, MouseEvent, useCallback } from 'react'; 10 | 11 | // Input 12 | type InputProps = { 13 | value: string; 14 | onChange: ChangeEvent; 15 | }; 16 | 17 | const Input: FC = ({ value, onChange }) => { 18 | return ; 19 | }; 20 | ``` 21 | 22 | ```tsx 23 | // ./app.tsx 24 | import { reflect } from '@effector/reflect'; 25 | import { createEvent, restore, sample, Scope } from 'effector'; 26 | import { Provider } from 'effector-react'; 27 | import React, { FC } from 'react'; 28 | 29 | import { Input } from './ui'; 30 | 31 | // Model 32 | export const appStarted = createEvent<{ name: string }>(); 33 | 34 | const changeName = createEvent(); 35 | const $name = restore(changeName, ''); 36 | 37 | sample({ 38 | clock: appStarted, 39 | fn: (ctx) => ctx.name, 40 | target: changeName, 41 | }); 42 | 43 | // Component 44 | const Name = reflect({ 45 | view: Input, 46 | bind: { 47 | value: $name, 48 | onChange: (event) => changeName(event.target.value), 49 | }, 50 | }); 51 | 52 | export const App: FC<{ scope: Scope }> = ({ scope }) => { 53 | return ( 54 | 55 | 56 | 57 | ); 58 | }; 59 | ``` 60 | 61 | ```tsx 62 | // ./server.tsx 63 | import { allSettled, fork, serialize } from 'effector'; 64 | 65 | import { App, appStarted } from './app'; 66 | 67 | const render = async (reqCtx) => { 68 | const serverScope = fork(); 69 | 70 | await allSettled(appStarted, { 71 | scope: serverScope, 72 | params: { 73 | name: reqCtx.cookies.name, 74 | }, 75 | }); 76 | 77 | const content = renderToString(); 78 | const data = serialize(scope); 79 | 80 | return ` 81 | 82 | ${content} 83 | 86 | 87 | `; 88 | }; 89 | ``` 90 | 91 | ```tsx 92 | // client.tsx 93 | import { fork } from 'effector'; 94 | import { hydrateRoot } from 'react-dom/client'; 95 | 96 | import { App, appStarted } from './app'; 97 | 98 | const clientScope = fork({ 99 | values: window.__initialState__, 100 | }); 101 | 102 | hydrateRoot(document.body, ); 103 | ``` 104 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@effector/reflect", 3 | "version": "0.0.0-real-version-will-be-set-on-ci", 4 | "repository": "effector/reflect", 5 | "description": "☄️ Attach effector stores to react components without hooks", 6 | "maintainers": [ 7 | "Sergey Sova (https://sova.dev)", 8 | "e.fedotov " 9 | ], 10 | "license": "MIT", 11 | "publishConfig": { 12 | "access": "public" 13 | }, 14 | "packageManager": "pnpm@10.6.5", 15 | "exports": { 16 | ".": { 17 | "types": "./index.d.ts", 18 | "import": "./index.mjs", 19 | "require": "./index.cjs", 20 | "default": "./index.mjs" 21 | }, 22 | "./scope": { 23 | "types": "./scope.d.ts", 24 | "import": "./scope.mjs", 25 | "require": "./scope.js", 26 | "default": "./scope.mjs" 27 | } 28 | }, 29 | "main": "index.cjs", 30 | "module": "index.mjs", 31 | "typings": "index.d.ts", 32 | "files": [ 33 | "Readme.md", 34 | "core", 35 | "index.d.ts", 36 | "ssr.d.ts", 37 | "scope.d.ts", 38 | "index.cjs", 39 | "index.cjs.map", 40 | "index.mjs", 41 | "index.mjs.map", 42 | "scope.js", 43 | "scope.js.map", 44 | "scope.mjs", 45 | "scope.mjs.map" 46 | ], 47 | "scripts": { 48 | "validate:dist": "node ./validate_dist.mjs", 49 | "test:code": "vitest run ./src", 50 | "test:types": "tsc -p ./type-tests", 51 | "test": "pnpm test:code && pnpm test:types", 52 | "build": "pnpm clear-build && node ./build.mjs", 53 | "clear-build": "rm -rf dist", 54 | "prepublishOnly": "pnpm build", 55 | "prepare": "husky install" 56 | }, 57 | "devDependencies": { 58 | "@arethetypeswrong/cli": "^0.13.10", 59 | "@babel/core": "^7.26.10", 60 | "@babel/plugin-transform-runtime": "^7.26.10", 61 | "@babel/preset-env": "^7.26.9", 62 | "@babel/preset-react": "^7.26.3", 63 | "@babel/preset-typescript": "^7.26.0", 64 | "@mantine/core": "^7.17.2", 65 | "@rollup/plugin-babel": "^6.0.4", 66 | "@rollup/plugin-commonjs": "^24.1.0", 67 | "@rollup/plugin-node-resolve": "^15.3.1", 68 | "@rollup/plugin-terser": "^0.4.4", 69 | "@testing-library/dom": "^10.4.0", 70 | "@testing-library/react": "^16.2.0", 71 | "@testing-library/user-event": "^14.6.1", 72 | "@trivago/prettier-plugin-sort-imports": "^4.3.0", 73 | "@types/jest": "^26.0.24", 74 | "@types/react": "^19.0.12", 75 | "@types/react-dom": "^19.0.4", 76 | "babel-jest": "^29.7.0", 77 | "babel-plugin-module-resolver": "^4.1.0", 78 | "effector": "^23.3.0", 79 | "effector-react": "^23.3.0", 80 | "eslint": "^8.57.1", 81 | "eslint-kit": "^6.12.0", 82 | "fs-extra": "^9.1.0", 83 | "husky": "^8.0.3", 84 | "jsdom": "^23.2.0", 85 | "lint-staged": "^13.3.0", 86 | "prettier": "^2.8.8", 87 | "pretty-ms": "^8.0.0", 88 | "publint": "^0.2.12", 89 | "react": "^19.0.0", 90 | "react-dom": "^19.0.0", 91 | "rollup": "^3.29.5", 92 | "rollup-plugin-typescript2": "^0.34.1", 93 | "svelte": "^3.59.2", 94 | "tsd": "^0.19.1", 95 | "typescript": "^5.8.2", 96 | "uglify-js": "^3.19.3", 97 | "vite-tsconfig-paths": "^4.3.2", 98 | "vitest": "^1.6.1", 99 | "zx": "^7.2.3" 100 | }, 101 | "peerDependencies": { 102 | "effector": "^23.1.0", 103 | "effector-react": "^23.1.0", 104 | "react": ">=16.8.0 <20.0.0" 105 | }, 106 | "dependencies": { 107 | "@vitejs/plugin-react": "^4.3.4" 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /docs/pages/docs/list.mdx: -------------------------------------------------------------------------------- 1 | # `list` 2 | 3 | ```ts 4 | import { list } from '@effector/reflect'; 5 | ``` 6 | 7 | ```tsx 8 | const Items: React.FC = list({ 9 | view: React.FC, 10 | source: Store, 11 | bind: { 12 | // regular reflect's bind, for list item view 13 | }, 14 | hooks: { 15 | // regular reflect's hooks, for list item view 16 | }, 17 | mapItem: { 18 | propName: (item: Item, index: number) => propValue, // maps array store item to View props 19 | }, 20 | getKey: (item: Item) => React.Key, // optional, will use index by default 21 | }); 22 | ``` 23 | 24 | Method creates component, which renders list of `view` components based on items in array in `source` store, each item content's will be mapped to View props by `mapItem` rules. On changes to `source` store, rendered list will be updated too 25 | 26 | ## Arguments 27 | 28 | 1. `source` — Store of `Item[]` value. 29 | 1. `view` — A react component, will be used to render list items 30 | 1. `mapItem` — Object `{ propName: (Item, index) => propValue }` that defines rules, by which every `Item` will be mapped to props of each rendered list item. 31 | 1. `bind` — Optional object of stores, events, and static values that will be bound as props to every list item. 32 | 1. [`hooks`](/docs/hooks) — Optional object `{ mounted, unmounted }` to handle when any list item component is mounted or unmounted. 33 | 1. `getKey` - Optional function `(item: Item) => React.Key` to set key for every item in the list to help React with effecient rerenders. If not provided, index is used. See [`effector-react`](https://effector.dev/docs/api/effector-react/useList) docs for more details. 34 | 1. `useUnitConfig` - Optional configuration object, which is passed directly to the second argument of `useUnit` from `effector-react`. 35 | 36 | ## Returns 37 | 38 | - A react component that renders a list of `view` components based on items of array in `source` store. Every `view` component props are bound to array item contents by the rules in `mapItem`, and to stores and events in `bind`, like with regular `reflect` 39 | 40 | ## Example 41 | 42 | ```tsx 43 | import { list } from '@effector/reflect'; 44 | import { createEvent, createStore } from 'effector'; 45 | import React from 'react'; 46 | 47 | const $color = createStore('red'); 48 | 49 | const $users = createStore([ 50 | { id: 1, name: 'Yung' }, 51 | { id: 2, name: 'Lean' }, 52 | { id: 3, name: 'Kyoto' }, 53 | { id: 4, name: 'Sesh' }, 54 | ]); 55 | 56 | const Item = ({ id, name, color }) => { 57 | return ( 58 |
  • 59 | {id} - {name} 60 |
  • 61 | ); 62 | }; 63 | 64 | const Items = list({ 65 | view: Item, 66 | source: $users, 67 | bind: { 68 | color: $color, 69 | }, 70 | mapItem: { 71 | id: (user) => user.id, 72 | name: (user) => user.name, 73 | }, 74 | getKey: (user) => `${user.id}${user.name}`, 75 | }); 76 | 77 | 78 | 79 | ; 80 | ``` 81 | 82 | ### Fork API auto-compatibility 83 | 84 | **This feature is available since `9.0.0` release of Reflect.** 85 | 86 | The `list` operator also supports automatic Fork API support for callbacks **created** from `mapItem`: 87 | 88 | ```ts 89 | const userChanged = createEvent(); 90 | const Items = list({ 91 | view: Item, 92 | source: $users, 93 | bind: { 94 | color: $color, 95 | }, 96 | mapItem: { 97 | id: (user) => user.id, 98 | name: (user) => user.name, 99 | onChange: (user) => (nextUser) => { 100 | userChanged({ oldUser: user, nextUser }); 101 | }, 102 | }, 103 | getKey: (user) => `${user.id}${user.name}`, 104 | }); 105 | ``` 106 | 107 | ☝️ Notice, how `mapItem.onChange` creates a event handler callback for every rendered item. Those callbacks are also binded to Scope, if it is provided. 108 | For more details read the "Fork API auto-compatibility" part of the `reflect` operator documentation. 109 | -------------------------------------------------------------------------------- /src/core/reflect.ts: -------------------------------------------------------------------------------- 1 | import { Effect, Event, is, scopeBind, Store } from 'effector'; 2 | import { useProvidedScope } from 'effector-react'; 3 | import React, { PropsWithoutRef, RefAttributes } from 'react'; 4 | 5 | import { BindProps, Context, Hooks, UseUnitConifg, View } from './types'; 6 | 7 | export interface ReflectConfig> { 8 | view: View; 9 | bind: Bind; 10 | hooks?: Hooks; 11 | useUnitConfig?: UseUnitConifg; 12 | } 13 | 14 | export function reflectCreateFactory(context: Context) { 15 | const reflect = reflectFactory(context); 16 | 17 | return function createReflect(view: View) { 18 | return = BindProps>( 19 | bind: Bind, 20 | params?: Pick, 'hooks' | 'useUnitConfig'>, 21 | ) => reflect({ view, bind, ...params }); 22 | }; 23 | } 24 | 25 | export function reflectFactory(context: Context) { 26 | return function reflect = BindProps>( 27 | config: ReflectConfig, 28 | ): React.ExoticComponent & RefAttributes> { 29 | const { stores, events, data, functions } = sortProps(config.bind); 30 | const hooks = sortProps(config.hooks || {}); 31 | 32 | return React.forwardRef((props: Props, ref) => { 33 | const storeProps = context.useUnit(stores, config.useUnitConfig); 34 | const eventsProps = context.useUnit(events as any, config.useUnitConfig); 35 | const functionProps = useBoundFunctions(functions); 36 | 37 | const finalProps: any = {}; 38 | 39 | if (ref) { 40 | finalProps.ref = ref; 41 | } 42 | 43 | const elementProps: Props = Object.assign( 44 | finalProps, 45 | storeProps, 46 | eventsProps, 47 | data, 48 | functionProps, 49 | props, 50 | ); 51 | 52 | const eventsHooks = context.useUnit(hooks.events as any, config.useUnitConfig); 53 | const functionsHooks = useBoundFunctions(hooks.functions); 54 | 55 | React.useEffect(() => { 56 | const hooks: Hooks = Object.assign({}, functionsHooks, eventsHooks); 57 | 58 | if (hooks.mounted) { 59 | hooks.mounted(elementProps); 60 | } 61 | 62 | return () => { 63 | if (hooks.unmounted) { 64 | hooks.unmounted(elementProps); 65 | } 66 | }; 67 | }, [eventsHooks, functionsHooks]); 68 | 69 | return React.createElement(config.view as any, elementProps as any); 70 | }); 71 | }; 72 | } 73 | 74 | function sortProps(props: T) { 75 | type GenericEvent = Event | Effect; 76 | 77 | const events: Record = {}; 78 | const stores: Record> = {}; 79 | const data: Record = {}; 80 | const functions: Record = {}; 81 | 82 | for (const key in props) { 83 | const value = props[key]; 84 | 85 | if (is.event(value) || is.effect(value)) { 86 | events[key] = value; 87 | } else if (is.store(value)) { 88 | stores[key] = value; 89 | } else if (typeof value === 'function') { 90 | functions[key] = value; 91 | } else { 92 | data[key] = value; 93 | } 94 | } 95 | 96 | return { events, stores, data, functions }; 97 | } 98 | 99 | function useBoundFunctions(functions: Record) { 100 | const scope = useProvidedScope(); 101 | 102 | return React.useMemo(() => { 103 | const boundFunctions: Record = {}; 104 | 105 | for (const key in functions) { 106 | const fn = functions[key]; 107 | 108 | boundFunctions[key] = scopeBind(fn, { scope: scope || undefined, safe: true }); 109 | } 110 | 111 | return boundFunctions; 112 | }, [scope, functions]); 113 | } 114 | -------------------------------------------------------------------------------- /docs/pages/docs/reflect.mdx: -------------------------------------------------------------------------------- 1 | # `reflect` 2 | 3 | ```ts 4 | import { reflect } from '@effector/reflect'; 5 | ``` 6 | 7 | ```tsx 8 | const Component = reflect({ 9 | view: SourceComponent, 10 | bind: Props, 11 | hooks: Hooks, 12 | }); 13 | ``` 14 | 15 | Static method to create a component bound to effector stores and events as stores. 16 | 17 | ## Arguments 18 | 19 | 1. `view` — A react component that should be used to bind to 20 | 1. `bind` — Object of effector stores, events or any value 21 | 1. [`hooks`](/docs/hooks) — Optional object `{ mounted, unmounted }` to handle when component is mounted or unmounted. 22 | 1. `useUnitConfig` - Optional configuration object, which is passed directly to the second argument of `useUnit` from `effector-react`. 23 | 24 | ## Returns 25 | 26 | - A react component with bound values from stores and events. 27 | 28 | ## Example 29 | 30 | ```tsx 31 | // ./user.tsx 32 | import { reflect } from '@effector/reflect'; 33 | import { createEvent, restore } from 'effector'; 34 | import React, { ChangeEvent, FC } from 'react'; 35 | 36 | // Base components 37 | type InputProps = { 38 | value: string; 39 | onChange: ChangeEvent; 40 | placeholder?: string; 41 | }; 42 | 43 | const Input: FC = ({ value, onChange, placeholder }) => { 44 | return ; 45 | }; 46 | 47 | // Model 48 | const changeName = createEvent(); 49 | const $name = restore(changeName, ''); 50 | 51 | const changeAge = createEvent(); 52 | const $age = restore(changeAge, 0); 53 | 54 | 55 | // Components 56 | const Name = reflect({ 57 | view: Input, 58 | bind: { 59 | value: $name, 60 | placeholder: 'Name', 61 | onChange: (event) => changeName(event.currentTarget.value), 62 | }, 63 | }); 64 | 65 | const Age = reflect({ 66 | view: Input, 67 | bind: { 68 | value: $age, 69 | placeholder: 'Age', 70 | onChange: (event) => changeAge(parseInt(event.currentTarget.value)), 71 | }, 72 | }); 73 | 74 | export const User: FC = () => { 75 | return ( 76 |
    77 | 78 | 79 |
    80 | ); 81 | }; 82 | ``` 83 | 84 | ### Fork API auto-compatibility 85 | 86 | **This feature is available since `9.0.0` release of Reflect.** 87 | 88 | The [Fork API](https://effector.dev/en/api/effector/fork/) - is a feature of Effector, which allows to seamlessly create virtualized instances of the application's state and logic called [Scope](https://effector.dev/en/api/effector/scope/)'s. 89 | In React to render an App in specific `Scope` a [Provider](https://effector.dev/en/api/effector-react/provider/) component should be used. 90 | 91 | When an external system (like React) calls an Effector event and Fork API is used - target `Scope` should be provided before call via `allSettled` or `scopeBind` API. 92 | 93 | The `reflect` operator does it for you under the hood, so you can provide arbitary callbacks into `bind`: 94 | 95 | ```tsx 96 | const Age = reflect({ 97 | view: Input, 98 | bind: { 99 | value: $age, 100 | placeholder: 'Age', 101 | onChange: (event) => { 102 | changeAge(parseInt(event.currentTarget.value)); 103 | }, 104 | }, 105 | }); 106 | ``` 107 | 108 | ☝️ This feature works for `bind` field in all of Reflect operators. 109 | 110 | In most cases your callbacks will be synchronous and you will not need to do anything besides using [Provider](https://effector.dev/en/api/effector-react/provider/) to set the Scope for the whole React tree. 111 | 112 | #### Special case: Asynchronous callbacks 113 | 114 | Asynchronous callbacks are also allowed, but those should follow the rules of [Imperative Effect calls with Scope](https://effector.dev/en/api/effector/scope/) to be compatible. 115 | 116 | ## TypeScript and Polymorphic Types Caveat 117 | 118 | Generally, reflect handles polymorphic props well. 119 | However, in some implementations, such as [Mantine UI](https://mantine.dev/guides/polymorphic/), it may not work as expected. 120 | 121 | In such cases, it is recommended to explicitly narrow the component type. For example: 122 | 123 | ```ts 124 | const ReflectedMantineButton = reflect({ 125 | view: MantineButton<'button'>, // <- notice explicit component type in the "<...>" brackets 126 | bind: { 127 | children: 'foo', 128 | onClick: (e) => { 129 | clicked(e.clientX); 130 | }, 131 | }, 132 | }); 133 | ``` 134 | 135 | -------------------------------------------------------------------------------- /src/ssr/create-reflect.test.tsx: -------------------------------------------------------------------------------- 1 | import { createReflect } from '@effector/reflect/scope'; 2 | import { act, render } from '@testing-library/react'; 3 | import userEvent from '@testing-library/user-event'; 4 | import { allSettled, createDomain, fork, restore } from 'effector'; 5 | import { Provider } from 'effector-react/scope'; 6 | import React, { ChangeEvent, FC, InputHTMLAttributes } from 'react'; 7 | 8 | // Example1 (InputCustom) 9 | const InputCustom: FC<{ 10 | value: string | number | string[]; 11 | onChange(value: string): void; 12 | testId: string; 13 | placeholder?: string; 14 | }> = (props) => { 15 | return ( 16 | props.onChange(event.currentTarget.value)} 21 | /> 22 | ); 23 | }; 24 | 25 | const inputCustom = createReflect(InputCustom); 26 | 27 | test('InputCustom', async () => { 28 | const app = createDomain(); 29 | 30 | const change = app.createEvent(); 31 | const $name = restore(change, ''); 32 | 33 | const Name = inputCustom({ value: $name, onChange: change }); 34 | 35 | const scope = fork(app); 36 | 37 | expect(scope.getState($name)).toBe(''); 38 | await allSettled(change, { scope, params: 'Bob' }); 39 | expect(scope.getState($name)).toBe('Bob'); 40 | 41 | const container = render( 42 | 43 | 44 | , 45 | ); 46 | 47 | const inputName = container.container.firstChild as HTMLInputElement; 48 | expect(inputName.value).toBe('Bob'); 49 | }); 50 | 51 | test('InputCustom [replace value]', async () => { 52 | const app = createDomain(); 53 | 54 | const change = app.createEvent(); 55 | const $name = app.createStore(''); 56 | 57 | $name.on(change, (_, next) => next); 58 | 59 | const Name = inputCustom({ name: $name, onChange: change }); 60 | 61 | const scope = fork(app); 62 | 63 | expect(scope.getState($name)).toBe(''); 64 | await allSettled(change, { scope, params: 'Bob' }); 65 | expect(scope.getState($name)).toBe('Bob'); 66 | 67 | const container = render( 68 | 69 | 70 | , 71 | ); 72 | 73 | const inputName = container.container.firstChild as HTMLInputElement; 74 | expect(inputName.value).toBe('Alise'); 75 | }); 76 | 77 | // Example 2 (InputBase) 78 | const InputBase: FC> = (props) => { 79 | return ; 80 | }; 81 | 82 | const inputBase = createReflect(InputBase); 83 | 84 | test('InputBase', async () => { 85 | const app = createDomain(); 86 | 87 | const changeName = app.createEvent(); 88 | const $name = restore(changeName, ''); 89 | 90 | const inputChanged = (event: ChangeEvent) => { 91 | return event.currentTarget.value; 92 | }; 93 | 94 | const Name = inputBase({ 95 | value: $name, 96 | onChange: changeName.prepend(inputChanged), 97 | }); 98 | 99 | const changeAge = app.createEvent(); 100 | const $age = restore(changeAge, 0); 101 | 102 | const Age = inputBase({ 103 | value: $age, 104 | onChange: changeAge.prepend(parseInt).prepend(inputChanged), 105 | }); 106 | 107 | const scope = fork(app); 108 | 109 | expect(scope.getState($name)).toBe(''); 110 | await allSettled(changeName, { scope, params: 'Bob' }); 111 | expect(scope.getState($name)).toBe('Bob'); 112 | 113 | expect(scope.getState($age)).toBe(0); 114 | await allSettled(changeAge, { scope, params: 25 }); 115 | expect(scope.getState($age)).toBe(25); 116 | 117 | const container = render( 118 | 119 | 120 | 121 | , 122 | ); 123 | 124 | const inputName = container.getByTestId('name') as HTMLInputElement; 125 | expect(inputName.value).toBe('Bob'); 126 | 127 | const inputAge = container.getByTestId('age') as HTMLInputElement; 128 | expect(inputAge.value).toBe('25'); 129 | }); 130 | 131 | test('with ssr for client', async () => { 132 | const app = createDomain(); 133 | 134 | const changeName = app.createEvent(); 135 | const $name = restore(changeName, ''); 136 | 137 | const Name = inputBase({ 138 | value: $name, 139 | onChange: changeName.prepend((event) => event.currentTarget.value), 140 | }); 141 | 142 | const scope = fork(app); 143 | 144 | const container = render( 145 | 146 | 147 | , 148 | ); 149 | 150 | expect($name.getState()).toBe(''); 151 | await userEvent.type(container.getByTestId('name'), 'Bob'); 152 | expect(scope.getState($name)).toBe('Bob'); 153 | 154 | const inputName = container.getByTestId('name') as HTMLInputElement; 155 | expect(inputName.value).toBe('Bob'); 156 | }); 157 | -------------------------------------------------------------------------------- /docs/pages/learn/motivation.mdx: -------------------------------------------------------------------------------- 1 | # Motivation 2 | 3 | What's the point of reflect? 4 | 5 | It's the API design that, using the classic HOC pattern, allows you to write React components with Effector in an efficient and composable way. 6 | 7 | ## The usual way 8 | 9 | Let's take a look at typical example of hooks usage: 10 | 11 | ```tsx 12 | import { useUnit } from 'effector-react'; 13 | import { Button, ErrorMessage, FormContainer, Input } from 'ui-lib'; 14 | 15 | import * as model from './form-model'; 16 | 17 | export function UserForm() { 18 | const { 19 | formValid, 20 | name, 21 | nameChanged, 22 | lastName, 23 | lastNameChanged, 24 | formSubmitted, 25 | error, 26 | } = useUnit({ 27 | formValid: model.$formValid, 28 | name: model.$name, 29 | nameChanged: model.nameChanged, 30 | lastName: model.lastNameChanged, 31 | formSubmitted: model.formSubmitted, 32 | error: model.$error, 33 | }); 34 | 35 | return ( 36 | 37 | 38 | 39 | {error && } 40 | ; 249 | } 250 | 251 | test('render "then" on true', () => { 252 | const $visible = createStore(true); 253 | 254 | const Component = variant({ 255 | if: $visible, 256 | then: Button, 257 | else: () =>
    , 258 | }); 259 | 260 | const container = render(); 261 | expect(container.getByTestId('then')).not.toBeNull(); 262 | }); 263 | 264 | test('render "else" on false', () => { 265 | const $visible = createStore(false); 266 | 267 | const Component = variant({ 268 | if: $visible, 269 | then: Button, 270 | else: () =>
    , 271 | }); 272 | 273 | const container = render(); 274 | expect(container.getByTestId('else')).not.toBeNull(); 275 | }); 276 | 277 | test('render "null" as default "else"', () => { 278 | const $visible = createStore(false); 279 | 280 | const Component = variant({ 281 | if: $visible, 282 | then: Button, 283 | }); 284 | 285 | const container = render(); 286 | expect(() => container.getByTestId('then')).toThrowError(); 287 | }); 288 | }); 289 | 290 | describe('useUnitConfig', () => { 291 | test('useUnit config should be passed to underlying useUnit', () => { 292 | expect(() => { 293 | const Test = variant({ 294 | source: createStore<'a' | 'b'>('a'), 295 | cases: { 296 | a: () => null, 297 | b: () => null, 298 | }, 299 | bind: {}, 300 | useUnitConfig: { 301 | forceScope: true, 302 | }, 303 | }); 304 | render(); 305 | }).toThrowErrorMatchingInlineSnapshot( 306 | `[Error: No scope found, consider adding to app root]`, 307 | ); 308 | }); 309 | test('useUnit config should be passed to underlying useUnit (bool overload)', () => { 310 | expect(() => { 311 | const Test = variant({ 312 | if: createStore(true), 313 | then: () => null, 314 | else: () => null, 315 | bind: {}, 316 | useUnitConfig: { 317 | forceScope: true, 318 | }, 319 | }); 320 | render(); 321 | }).toThrowErrorMatchingInlineSnapshot( 322 | `[Error: No scope found, consider adding to app root]`, 323 | ); 324 | }); 325 | }); 326 | -------------------------------------------------------------------------------- /type-tests/types-variant.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import { reflect, variant } from '@effector/reflect'; 3 | import { Button } from '@mantine/core'; 4 | import { createEvent, createStore } from 'effector'; 5 | import React, { FC, PropsWithChildren, ReactNode } from 'react'; 6 | import { expectType } from 'tsd'; 7 | 8 | // basic variant usage 9 | { 10 | const Input: React.FC<{ 11 | value: string; 12 | onChange: (newValue: string) => void; 13 | }> = () => null; 14 | const DateTime: React.FC<{ 15 | value: string; 16 | onChange: (newValue: string) => void; 17 | }> = () => null; 18 | const $value = createStore(''); 19 | const changed = createEvent(); 20 | const $type = createStore<'input' | 'datetime'>('input'); 21 | 22 | const VariableInput = variant({ 23 | source: $type, 24 | bind: { 25 | value: $value, 26 | onChange: changed, 27 | }, 28 | cases: { 29 | input: Input, 30 | datetime: DateTime, 31 | }, 32 | }); 33 | 34 | ; 35 | } 36 | 37 | // variant allows to pass incompatible props between cases - resulting component will have union of all props from all cases 38 | { 39 | const Input: React.FC<{ 40 | value: string; 41 | onChange: (event: { target: { value: string } }) => void; 42 | }> = () => null; 43 | const DateTime: React.FC<{ 44 | value: string; 45 | onChange: (newValue: string) => void; 46 | }> = () => null; 47 | const $value = createStore(''); 48 | const changed = createEvent(); 49 | const $type = createStore<'input' | 'datetime'>('input'); 50 | 51 | const VariableInput = variant({ 52 | source: $type, 53 | bind: { 54 | value: $value, 55 | onChange: changed, 56 | }, 57 | cases: { 58 | input: Input, 59 | datetime: DateTime, 60 | }, 61 | }); 62 | 63 | ; 64 | ; 65 | { 68 | event.target.value; 69 | }} 70 | />; 71 | { 74 | // ok 75 | }} 76 | />; 77 | { 80 | event; 81 | }} 82 | />; 83 | { 88 | event; 89 | }} 90 | />; 91 | } 92 | 93 | // variant allows not to set every possble case 94 | // for e.g. if we want to cover only specific ones and render default for the rest 95 | { 96 | type PageProps = { 97 | context: { 98 | route: string; 99 | }; 100 | }; 101 | 102 | const HomePage: React.FC = () => null; 103 | const FaqPage: React.FC = () => null; 104 | const NotFoundPage: React.FC = () => null; 105 | const $page = createStore<'home' | 'faq' | 'profile' | 'products'>('home'); 106 | const $pageContext = $page.map((route) => ({ route })); 107 | 108 | const CurrentPage = variant({ 109 | source: $page, 110 | bind: { context: $pageContext }, 111 | cases: { 112 | home: HomePage, 113 | faq: FaqPage, 114 | }, 115 | default: NotFoundPage, 116 | }); 117 | 118 | ; 119 | ; 120 | // @ts-expect-error 121 | ; 122 | } 123 | 124 | // variant warns about wrong cases 125 | { 126 | type PageProps = { 127 | context: { 128 | route: string; 129 | }; 130 | }; 131 | 132 | const HomePage: React.FC = () => null; 133 | const FaqPage: React.FC = () => null; 134 | const NotFoundPage: React.FC = () => null; 135 | const $page = createStore<'home' | 'profile' | 'products'>('home'); 136 | const $pageContext = $page.map((route) => ({ route })); 137 | 138 | const CurrentPage = variant({ 139 | source: $page, 140 | bind: { context: $pageContext }, 141 | cases: { 142 | home: HomePage, 143 | // @ts-expect-error 144 | faq: FaqPage, 145 | }, 146 | default: NotFoundPage, 147 | }); 148 | 149 | ; 150 | ; 151 | // @ts-expect-error 152 | ; 153 | } 154 | 155 | // overload for boolean source 156 | { 157 | type PageProps = { 158 | context: { 159 | route: string; 160 | }; 161 | }; 162 | 163 | const $ctx = createStore({ route: 'home' }); 164 | 165 | const HomePage: React.FC = () => null; 166 | const FallbackPage: React.FC = () => null; 167 | const $enabled = createStore(true); 168 | 169 | const CurrentPageThenElse = variant({ 170 | if: $enabled, 171 | then: HomePage, 172 | else: FallbackPage, 173 | bind: { context: $ctx }, 174 | }); 175 | 176 | ; 177 | ; 178 | // @ts-expect-error 179 | ; 180 | 181 | const CurrentPageOnlyThen = variant({ 182 | if: $enabled, 183 | then: HomePage, 184 | bind: { context: $ctx }, 185 | }); 186 | ; 187 | ; 188 | // @ts-expect-error 189 | ; 190 | } 191 | 192 | // supports nesting 193 | { 194 | const Test = (props: { test: string }) => <>; 195 | 196 | const $test = createStore('test'); 197 | const $bool = createStore(true); 198 | 199 | const NestedVariant = variant({ 200 | source: $test, 201 | cases: { 202 | test: variant({ 203 | if: $bool, 204 | then: reflect({ 205 | view: Test, 206 | bind: {}, 207 | }), 208 | }), 209 | }, 210 | }); 211 | 212 | ; 213 | } 214 | 215 | // allows variants of compatible types 216 | { 217 | const Test = (props: PropsWithChildren<{ test: string }>) =>
    content
    ; 218 | const Loader = () => <>loader; 219 | 220 | const $test = createStore(false); 221 | 222 | const View = variant({ 223 | if: $test, 224 | then: reflect({ 225 | view: Test, 226 | bind: { 227 | test: $test.map(() => 'test'), 228 | }, 229 | }), 230 | else: Loader, 231 | }); 232 | 233 | ; 234 | // @ts-expect-error 235 | ; 236 | } 237 | 238 | // Issue #81 reproduce 1 239 | { 240 | const Component = (props: { name: string }) => { 241 | return null; 242 | }; 243 | 244 | const Variant = variant({ 245 | source: createStore<'a'>('a'), 246 | cases: { a: Component }, 247 | }); 248 | 249 | // should not report error here 250 | const element = ; 251 | } 252 | 253 | // Issue #81 reproduce 2 254 | { 255 | const $enabled = createStore(true); 256 | const $name = createStore('reflect'); 257 | 258 | const MyView: FC<{ name: string; slot: ReactNode }> = ({ name, slot }) => { 259 | return ( 260 |
    261 |

    Hello, {name}!

    262 | {slot} 263 |
    264 | ); 265 | }; 266 | 267 | const ComponentWithReflectOnly = reflect({ 268 | bind: { 269 | name: $name, 270 | }, 271 | view: MyView, 272 | }); 273 | 274 | const ComponentWithVariantAndReflect = variant({ 275 | if: $enabled, 276 | then: reflect({ 277 | bind: { 278 | name: $name, 279 | }, 280 | view: MyView, 281 | }), 282 | }); 283 | 284 | // Should not report error for "Another slot type" 285 | const App = () => { 286 | return ( 287 |
    288 | Good slot type} /> 289 | Another slot type(} /> 290 | Should report error for "name"} 292 | name="kek" 293 | /> 294 |
    295 | ); 296 | }; 297 | } 298 | 299 | // Edge-case: Mantine Button with weird polymorphic factory (fails) 300 | // 301 | // This case is broken because Button has a weird polymorphic factory 302 | // that is not compatible with `variant` as of now - it produces weird and broken types for resulting component 303 | // Test is left here for the future reference, in case if it will be possible to fix 304 | { 305 | const ReflectedVariant = variant({ 306 | source: createStore<'button' | 'a'>('button'), 307 | bind: { 308 | size: 'xl', 309 | }, 310 | cases: { 311 | button: Button<'button'>, 312 | a: Button<'a'>, 313 | }, 314 | }); 315 | 316 | const ReflectedVariantBad = variant({ 317 | source: createStore<'button' | 'a'>('button'), 318 | bind: { 319 | // @ts-expect-error 320 | size: 52, 321 | }, 322 | cases: { 323 | button: Button<'button'>, 324 | a: Button<'a'>, 325 | }, 326 | }); 327 | 328 | ; 329 | ; 330 | // @ts-expect-error 331 | ; 332 | 333 | const IfElseVariant = variant({ 334 | if: createStore(true), 335 | then: Button<'button'>, 336 | // @ts-expect-error 337 | else: Button<'a'>, 338 | }); 339 | 340 | ; 341 | ; 342 | // @ts-expect-error 343 | ; 344 | } 345 | 346 | // variant should allow not-to pass required props - as they can be added later in react 347 | { 348 | const Input: React.FC<{ 349 | value: string; 350 | onChange: (newValue: string) => void; 351 | color: 'red'; 352 | }> = () => null; 353 | const $variants = createStore<'input' | 'fallback'>('input'); 354 | const Fallback: React.FC<{ kek?: string }> = () => null; 355 | const $value = createStore(''); 356 | const changed = createEvent(); 357 | 358 | const InputBase = reflect({ 359 | view: Input, 360 | bind: { 361 | value: $value, 362 | onChange: changed, 363 | }, 364 | }); 365 | 366 | const ReflectedInput = variant({ 367 | source: $variants, 368 | cases: { 369 | input: InputBase, 370 | fallback: Fallback, 371 | }, 372 | }); 373 | 374 | const App: React.FC = () => { 375 | // missing prop must still be required in react 376 | // but in this case it is not required, as props are conditional union 377 | return ; 378 | }; 379 | 380 | ; 381 | 382 | const AppFixed: React.FC = () => { 383 | return ; 384 | }; 385 | expectType(App); 386 | expectType(AppFixed); 387 | } 388 | -------------------------------------------------------------------------------- /src/ssr/list.test.tsx: -------------------------------------------------------------------------------- 1 | import { list } from '@effector/reflect/scope'; 2 | import { act, render } from '@testing-library/react'; 3 | import { allSettled, createDomain, fork } from 'effector'; 4 | import { Provider, useStore } from 'effector-react/scope'; 5 | import React, { FC, memo } from 'react'; 6 | 7 | const List: FC = (props) => { 8 | return
      {props.children}
    ; 9 | }; 10 | 11 | const ListItem: FC<{ title: string; prefix?: string }> = (props) => { 12 | return ( 13 |
  • {`${ 14 | props.prefix || '' 15 | }${props.title}`}
  • 16 | ); 17 | }; 18 | 19 | test('relfect-list: renders list from store', async () => { 20 | const app = createDomain(); 21 | 22 | const $todos = app.createStore<{ title: string; body: string }[]>([ 23 | { title: 'Buy milk', body: 'Text' }, 24 | { title: 'Clean room', body: 'Text 2' }, 25 | { title: 'Do homework', body: 'Text 3' }, 26 | ]); 27 | 28 | const Items = list({ 29 | source: $todos, 30 | view: ListItem, 31 | bind: {}, 32 | mapItem: { 33 | title: (todo) => todo.title, 34 | }, 35 | }); 36 | 37 | const scope = fork(app); 38 | 39 | const container = render( 40 | 41 | 42 | 43 | 44 | , 45 | ); 46 | 47 | expect( 48 | container.getAllByRole('listitem').map((item) => item.dataset.testid), 49 | ).toEqual(scope.getState($todos).map((todo) => todo.title)); 50 | }); 51 | 52 | test('relfect-list: reflect hooks called once for every item', async () => { 53 | const app = createDomain(); 54 | 55 | const $todos = app.createStore<{ title: string; body: string }[]>([ 56 | { title: 'Buy milk', body: 'Text' }, 57 | { title: 'Clean room', body: 'Text 2' }, 58 | { title: 'Do homework', body: 'Text 3' }, 59 | ]); 60 | 61 | const mounted = app.createEvent(); 62 | 63 | const fn = vi.fn(() => {}); 64 | 65 | mounted.watch(fn); 66 | 67 | const unmounted = app.createEvent(); 68 | 69 | const unfn = vi.fn(() => {}); 70 | 71 | unmounted.watch(unfn); 72 | 73 | const Items = list({ 74 | source: $todos, 75 | view: ListItem, 76 | bind: {}, 77 | hooks: { mounted, unmounted }, 78 | mapItem: { 79 | title: (todo) => todo.title, 80 | }, 81 | }); 82 | 83 | const scope = fork(app); 84 | 85 | const container = render( 86 | 87 | 88 | 89 | 90 | , 91 | ); 92 | 93 | expect(fn.mock.calls.length).toBe(scope.getState($todos).length); 94 | 95 | container.unmount(); 96 | 97 | expect(unfn.mock.calls.length).toBe(scope.getState($todos).length); 98 | }); 99 | 100 | test('reflect-list: rerenders on list changes', async () => { 101 | const app = createDomain(); 102 | 103 | const addTodo = app.createEvent<{ title: string; body: string }>(); 104 | const removeTodo = app.createEvent(); 105 | const $todos = app.createStore<{ title: string; body: string }[]>([ 106 | { title: 'Buy milk', body: 'Text' }, 107 | { title: 'Clean room', body: 'Text 2' }, 108 | { title: 'Do homework', body: 'Text 3' }, 109 | ]); 110 | 111 | $todos 112 | .on(addTodo, (todos, next) => todos.concat(next)) 113 | .on(removeTodo, (todos, toRemove) => 114 | todos.filter((todo) => todo.title !== toRemove), 115 | ); 116 | 117 | const Items = list({ 118 | source: $todos, 119 | view: ListItem, 120 | bind: {}, 121 | mapItem: { 122 | title: (todo) => todo.title, 123 | }, 124 | }); 125 | 126 | const scope = fork(app); 127 | 128 | const container = render( 129 | 130 | 131 | 132 | 133 | , 134 | ); 135 | 136 | expect( 137 | container.getAllByRole('listitem').map((item) => item.dataset.testid), 138 | ).toEqual(scope.getState($todos).map((todo) => todo.title)); 139 | 140 | await act(async () => { 141 | await allSettled(addTodo, { 142 | scope, 143 | params: { title: 'Write tests', body: 'Text 4' }, 144 | }); 145 | }); 146 | 147 | expect( 148 | container.getAllByRole('listitem').map((item) => item.dataset.testid), 149 | ).toEqual(scope.getState($todos).map((todo) => todo.title)); 150 | 151 | await act(async () => { 152 | await allSettled(removeTodo, { scope, params: 'Clean room' }); 153 | }); 154 | 155 | expect( 156 | container.getAllByRole('listitem').map((item) => item.dataset.testid), 157 | ).toEqual(scope.getState($todos).map((todo) => todo.title)); 158 | }); 159 | 160 | test('reflect-list: reflect binds props to every item in the list', async () => { 161 | const app = createDomain(); 162 | 163 | const $todos = app.createStore<{ title: string; body: string }[]>([ 164 | { title: 'Buy milk', body: 'Text' }, 165 | { title: 'Clean room', body: 'Text 2' }, 166 | { title: 'Do homework', body: 'Text 3' }, 167 | ]); 168 | 169 | const $prefix = app.createStore(''); 170 | const prefix = app.createEvent(); 171 | 172 | $prefix.on(prefix, (_, next) => next); 173 | 174 | const Items = list({ 175 | source: $todos, 176 | view: ListItem, 177 | bind: { 178 | prefix: $prefix, 179 | }, 180 | mapItem: { 181 | title: (todo) => todo.title, 182 | }, 183 | }); 184 | 185 | const scope = fork(app); 186 | 187 | const container = render( 188 | 189 | 190 | 191 | 192 | , 193 | ); 194 | 195 | expect( 196 | container.getAllByRole('listitem').map((item) => item.dataset.prefix), 197 | ).toEqual(scope.getState($todos).map(() => scope.getState($prefix))); 198 | 199 | await act(async () => { 200 | await allSettled(prefix, { scope, params: 'Task: ' }); 201 | }); 202 | 203 | expect( 204 | container.getAllByRole('listitem').map((item) => item.dataset.prefix), 205 | ).toEqual(scope.getState($todos).map(() => scope.getState($prefix))); 206 | 207 | await act(async () => { 208 | await allSettled(prefix, { scope, params: '' }); 209 | }); 210 | 211 | expect( 212 | container.getAllByRole('listitem').map((item) => item.dataset.prefix), 213 | ).toEqual(scope.getState($todos).map(() => scope.getState($prefix))); 214 | }); 215 | 216 | interface MemberProps { 217 | id: number; 218 | name: string; 219 | } 220 | 221 | const Member: FC = (props) => { 222 | const { name, id } = props; 223 | 224 | return
  • {name}
  • ; 225 | }; 226 | 227 | test('reflect-list: getKey option', async () => { 228 | const fn = vi.fn(); 229 | const fn2 = vi.fn(); 230 | const app = createDomain(); 231 | 232 | const renameUser = app.createEvent<{ id: number; name: string }>(); 233 | const removeUser = app.createEvent(); 234 | const sortById = app.createEvent(); 235 | const $members = app 236 | .createStore([ 237 | { name: 'alice', id: 1 }, 238 | { name: 'bob', id: 3 }, 239 | { name: 'carol', id: 2 }, 240 | ]) 241 | .on(renameUser, (list, { id, name }) => 242 | list.map((e) => (e.id === id ? { id, name } : e)), 243 | ) 244 | .on(removeUser, (list, id) => list.filter((e) => e.id !== id)) 245 | .on(sortById, (list) => [...list].sort((a, b) => a.id - b.id)); 246 | 247 | // plain 248 | const PlainMember = memo((props: MemberProps) => { 249 | fn2(props); 250 | return ; 251 | }); 252 | 253 | const PlainMemberList = () => { 254 | return ( 255 |
      256 | {useStore($members).map((props) => ( 257 | 258 | ))} 259 |
    260 | ); 261 | }; 262 | 263 | // reflect 264 | const ReflectMember: FC = (props) => { 265 | fn(props); 266 | return ; 267 | }; 268 | const ReflectList: FC = (props) =>
      {props.children}
    ; 269 | const Members = list({ 270 | source: $members, 271 | view: ReflectMember, 272 | bind: {}, 273 | mapItem: { 274 | id: (member) => member.id, 275 | name: (member) => member.name, 276 | }, 277 | getKey: (item) => item.id, 278 | }); 279 | 280 | const scope = fork(app); 281 | const App = () => ( 282 | 283 | 284 | 285 | 286 | 287 | 288 | ); 289 | 290 | const container = render(); 291 | 292 | // first check 293 | expect( 294 | Array.from(container.getByTestId('plain').querySelectorAll('li')).map((item) => 295 | Number(item.dataset.testid), 296 | ), 297 | ).toEqual(scope.getState($members).map((member) => member.id)); 298 | expect( 299 | Array.from(container.getByTestId('reflect').querySelectorAll('li')).map((item) => 300 | Number(item.dataset.testid), 301 | ), 302 | ).toEqual(scope.getState($members).map((member) => member.id)); 303 | 304 | expect(fn.mock.calls.map(([arg]) => arg)).toEqual( 305 | fn2.mock.calls.map(([arg]) => arg), 306 | ); 307 | 308 | await act(async () => { 309 | await allSettled(sortById, { scope }); 310 | }); 311 | 312 | // second check 313 | expect( 314 | Array.from(container.getByTestId('plain').querySelectorAll('li')).map((item) => 315 | Number(item.dataset.testid), 316 | ), 317 | ).toEqual(scope.getState($members).map((member) => member.id)); 318 | expect( 319 | Array.from(container.getByTestId('reflect').querySelectorAll('li')).map((item) => 320 | Number(item.dataset.testid), 321 | ), 322 | ).toEqual(scope.getState($members).map((member) => member.id)); 323 | 324 | expect(fn.mock.calls.map(([arg]) => arg)).toEqual( 325 | fn2.mock.calls.map(([arg]) => arg), 326 | ); 327 | 328 | await act(async () => { 329 | await allSettled(renameUser, { scope, params: { id: 2, name: 'charlie' } }); 330 | }); 331 | 332 | // third check 333 | expect( 334 | Array.from(container.getByTestId('plain').querySelectorAll('li')).map((item) => 335 | Number(item.dataset.testid), 336 | ), 337 | ).toEqual(scope.getState($members).map((member) => member.id)); 338 | expect( 339 | Array.from(container.getByTestId('reflect').querySelectorAll('li')).map((item) => 340 | Number(item.dataset.testid), 341 | ), 342 | ).toEqual(scope.getState($members).map((member) => member.id)); 343 | 344 | expect(fn.mock.calls.map(([arg]) => arg)).toEqual( 345 | fn2.mock.calls.map(([arg]) => arg), 346 | ); 347 | 348 | await act(async () => { 349 | await allSettled(removeUser, { scope, params: 2 }); 350 | }); 351 | 352 | // last check 353 | expect( 354 | Array.from(container.getByTestId('plain').querySelectorAll('li')).map((item) => 355 | Number(item.dataset.testid), 356 | ), 357 | ).toEqual(scope.getState($members).map((member) => member.id)); 358 | expect( 359 | Array.from(container.getByTestId('reflect').querySelectorAll('li')).map((item) => 360 | Number(item.dataset.testid), 361 | ), 362 | ).toEqual(scope.getState($members).map((member) => member.id)); 363 | 364 | expect(fn.mock.calls.map(([arg]) => arg)).toEqual( 365 | fn2.mock.calls.map(([arg]) => arg), 366 | ); 367 | }); 368 | -------------------------------------------------------------------------------- /type-tests/types-reflect.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | import { reflect } from '@effector/reflect'; 3 | import { Button } from '@mantine/core'; 4 | import { createEvent, createStore } from 'effector'; 5 | import React, { 6 | AnchorHTMLAttributes, 7 | ButtonHTMLAttributes, 8 | ComponentType, 9 | FC, 10 | LabelHTMLAttributes, 11 | PropsWithChildren, 12 | ReactNode, 13 | } from 'react'; 14 | import { expectType } from 'tsd'; 15 | 16 | // basic reflect 17 | { 18 | const Input: React.FC<{ 19 | value: string; 20 | onChange: (newValue: string) => void; 21 | color: 'red'; 22 | }> = () => null; 23 | const $value = createStore(''); 24 | const changed = createEvent(); 25 | 26 | const ReflectedInput = reflect({ 27 | view: Input, 28 | bind: { 29 | value: $value, 30 | onChange: changed, 31 | color: 'red', 32 | }, 33 | }); 34 | 35 | expectType(ReflectedInput); 36 | } 37 | 38 | // reflect should not allow wrong props 39 | { 40 | const Input: React.FC<{ 41 | value: string; 42 | onChange: (newValue: string) => void; 43 | color: 'red'; 44 | }> = () => null; 45 | const $value = createStore(''); 46 | const changed = createEvent(); 47 | 48 | const ReflectedInput = reflect({ 49 | view: Input, 50 | bind: { 51 | value: $value, 52 | onChange: changed, 53 | // @ts-expect-error 54 | color: 'blue', 55 | }, 56 | }); 57 | 58 | expectType(ReflectedInput); 59 | } 60 | 61 | // reflect should not allow wrong props in final types 62 | { 63 | const Input: React.FC<{ 64 | value: string; 65 | onChange: (newValue: string) => void; 66 | color: 'red'; 67 | }> = () => null; 68 | const $value = createStore(''); 69 | const changed = createEvent(); 70 | 71 | const ReflectedInput = reflect({ 72 | view: Input, 73 | bind: { 74 | value: $value, 75 | onChange: changed, 76 | }, 77 | }); 78 | 79 | const App: React.FC = () => { 80 | return ( 81 | 85 | ); 86 | }; 87 | expectType(App); 88 | } 89 | 90 | // reflect should allow not-to pass required props - as they can be added later in react 91 | { 92 | const Input: React.FC<{ 93 | value: string; 94 | onChange: (newValue: string) => void; 95 | color: 'red'; 96 | }> = () => null; 97 | const $value = createStore(''); 98 | const changed = createEvent(); 99 | 100 | const ReflectedInput = reflect({ 101 | view: Input, 102 | bind: { 103 | value: $value, 104 | onChange: changed, 105 | }, 106 | }); 107 | 108 | const App: React.FC = () => { 109 | // missing prop must still be required in react 110 | // @ts-expect-error 111 | return ; 112 | }; 113 | 114 | const AppFixed: React.FC = () => { 115 | return ; 116 | }; 117 | expectType(App); 118 | expectType(AppFixed); 119 | } 120 | 121 | // reflect should make "binded" props optional - so it is allowed to overwrite them in react anyway 122 | { 123 | const Input: React.FC<{ 124 | value: string; 125 | onChange: (newValue: string) => void; 126 | color: 'red'; 127 | }> = () => null; 128 | const $value = createStore(''); 129 | const changed = createEvent(); 130 | 131 | const ReflectedInput = reflect({ 132 | view: Input, 133 | bind: { 134 | value: $value, 135 | onChange: changed, 136 | }, 137 | }); 138 | 139 | const App: React.FC = () => { 140 | return ; 141 | }; 142 | 143 | const AppFixed: React.FC = () => { 144 | return ; 145 | }; 146 | expectType(App); 147 | expectType(AppFixed); 148 | } 149 | 150 | // reflect should not allow to override "binded" props with wrong types 151 | { 152 | const Input: React.FC<{ 153 | value: string; 154 | onChange: (newValue: string) => void; 155 | color: 'red'; 156 | }> = () => null; 157 | const $value = createStore(''); 158 | const changed = createEvent(); 159 | 160 | const ReflectedInput = reflect({ 161 | view: Input, 162 | bind: { 163 | value: $value, 164 | onChange: changed, 165 | color: 'red', 166 | }, 167 | }); 168 | 169 | const App: React.FC = () => { 170 | return ( 171 | 175 | ); 176 | }; 177 | expectType(App); 178 | } 179 | 180 | // reflect should allow to pass EventCallable as click event handler 181 | { 182 | const Button: React.FC<{ 183 | onClick: React.EventHandler>; 184 | }> = () => null; 185 | 186 | const reactOnClick = createEvent(); 187 | 188 | const ReflectedButton = reflect({ 189 | view: Button, 190 | bind: { 191 | onClick: reactOnClick, 192 | }, 193 | }); 194 | 195 | expectType(ReflectedButton); 196 | } 197 | 198 | // reflect should allow passing Event as callback to optional event handlers 199 | { 200 | const Button: React.FC<{ 201 | onOptional?: React.EventHandler>; 202 | onNull: React.MouseEventHandler | null; 203 | }> = () => null; 204 | 205 | const event = createEvent(); 206 | 207 | const ReflectedButton = reflect({ 208 | view: Button, 209 | bind: { 210 | onOptional: event, 211 | onNull: event, 212 | }, 213 | }); 214 | 215 | expectType(ReflectedButton); 216 | } 217 | 218 | // reflect should not allow binding ref 219 | { 220 | const Text = React.forwardRef( 221 | (_: { value: string }, ref: React.ForwardedRef) => null, 222 | ); 223 | 224 | const ReflectedText = reflect({ 225 | view: Text, 226 | bind: { 227 | // @ts-expect-error 228 | ref: React.createRef(), 229 | }, 230 | }); 231 | 232 | expectType(ReflectedText); 233 | } 234 | 235 | // reflect should pass ref through 236 | { 237 | const $value = createStore(''); 238 | const Text = React.forwardRef( 239 | (_: { value: string }, ref: React.ForwardedRef) => null, 240 | ); 241 | 242 | const ReflectedText = reflect({ 243 | view: Text, 244 | bind: { value: $value }, 245 | }); 246 | 247 | const App: React.FC = () => { 248 | const ref = React.useRef(null); 249 | 250 | return ; 251 | }; 252 | 253 | expectType(App); 254 | } 255 | 256 | // reflect should allow to pass any callback 257 | { 258 | const Input: React.FC<{ 259 | value: string; 260 | onChange: (newValue: string) => void; 261 | }> = () => null; 262 | const changed = createEvent(); 263 | 264 | const ReflectedInput = reflect({ 265 | view: Input, 266 | bind: { 267 | value: 'plain string', 268 | onChange: (e) => { 269 | expectType(e); 270 | changed(e); 271 | }, 272 | }, 273 | }); 274 | 275 | expectType(ReflectedInput); 276 | } 277 | 278 | // should allow store with a function as a callback value 279 | { 280 | const Input: React.FC<{ 281 | value: string; 282 | onChange: (newValue: string) => void; 283 | }> = () => null; 284 | const $changed = createStore<(newValue: string) => void>(() => {}); 285 | 286 | const ReflectedInput = reflect({ 287 | view: Input, 288 | bind: { 289 | value: 'plain string', 290 | onChange: $changed, 291 | }, 292 | }); 293 | 294 | expectType(ReflectedInput); 295 | } 296 | 297 | function localize(value: T): { lol: boolean }; 298 | function localize(value: T): { kek: boolean }; 299 | function localize(value: string): unknown { 300 | return value; 301 | } 302 | 303 | // should allow store with generics 304 | { 305 | const Input: React.FC<{ 306 | value: string; 307 | onChange: typeof localize; 308 | }> = () => null; 309 | const $changed = createStore(localize); 310 | 311 | const ReflectedInput = reflect({ 312 | view: Input, 313 | bind: { 314 | value: 'plain string', 315 | onChange: $changed, 316 | }, 317 | }); 318 | 319 | expectType(ReflectedInput); 320 | } 321 | 322 | // should support useUnit configuration 323 | { 324 | const Input: React.FC<{ 325 | value: string; 326 | onChange: (newValue: string) => void; 327 | }> = () => null; 328 | const changed = createEvent(); 329 | 330 | const ReflectedInput = reflect({ 331 | view: Input, 332 | bind: { 333 | value: 'plain string', 334 | onChange: (e) => { 335 | expectType(e); 336 | changed(e); 337 | }, 338 | }, 339 | useUnitConfig: { 340 | forceScope: true, 341 | }, 342 | }); 343 | } 344 | 345 | // should not support invalud useUnit configuration 346 | { 347 | const Input: React.FC<{ 348 | value: string; 349 | onChange: (newValue: string) => void; 350 | }> = () => null; 351 | const changed = createEvent(); 352 | 353 | const ReflectedInput = reflect({ 354 | view: Input, 355 | bind: { 356 | value: 'plain string', 357 | onChange: (e) => { 358 | expectType(e); 359 | changed(e); 360 | }, 361 | }, 362 | useUnitConfig: { 363 | // @ts-expect-error 364 | forseScope: true, 365 | }, 366 | }); 367 | } 368 | 369 | // reflect fits ComponentType 370 | { 371 | const Input = (props: PropsWithChildren<{ value: string }>) => null; 372 | 373 | const ReflectedInput = reflect({ 374 | view: Input, 375 | bind: { 376 | value: 'plain string', 377 | }, 378 | }); 379 | 380 | const Test: ComponentType<{ value: string; children: ReactNode }> = Input; 381 | } 382 | 383 | // reflect supports mounted as EventCallable 384 | { 385 | type Props = { loading: boolean }; 386 | 387 | const mounted = createEvent(); 388 | const unmounted = createEvent(); 389 | 390 | const Foo: FC = (props) => <>; 391 | 392 | const $loading = createStore(true); 393 | 394 | const Bar = reflect({ 395 | view: Foo, 396 | bind: { 397 | loading: $loading, 398 | }, 399 | hooks: { mounted, unmounted }, 400 | }); 401 | } 402 | 403 | // reflect supports mounted as EventCallable 404 | { 405 | type Props = { loading: boolean }; 406 | 407 | const mounted = createEvent(); 408 | const unmounted = createEvent(); 409 | 410 | const Foo: FC = (props) => <>; 411 | 412 | const $loading = createStore(true); 413 | 414 | const Bar = reflect({ 415 | view: Foo, 416 | bind: { 417 | loading: $loading, 418 | }, 419 | hooks: { mounted, unmounted }, 420 | }); 421 | } 422 | 423 | // should error if mounted event doesn't satisfy component props 424 | { 425 | const mounted = createEvent<{ foo: string }>(); 426 | const unmounted = createEvent<{ foo: string }>(); 427 | 428 | const Foo: FC<{ bar: number }> = () => null; 429 | 430 | const Bar = reflect({ 431 | view: Foo, 432 | // @ts-expect-error 433 | hooks: { mounted, unmounted }, 434 | }); 435 | } 436 | 437 | // reflect supports partial match of mounted event and component props 438 | { 439 | const mounted = createEvent<{ foo: string }>(); 440 | const unmounted = createEvent<{ foo: string }>(); 441 | 442 | const Foo: FC<{ foo: string; bar: number }> = () => null; 443 | 444 | const Bar = reflect({ 445 | view: Foo, 446 | bind: { 447 | foo: 'foo', 448 | bar: 42, 449 | }, 450 | hooks: { mounted, unmounted }, 451 | }); 452 | } 453 | 454 | // reflect supports partial match of mounted callback and component props 455 | { 456 | const mounted = (args: { foo: string }) => {}; 457 | const unmounted = (args: { foo: string }) => {}; 458 | 459 | const Foo: FC<{ foo: string; bar: number }> = () => null; 460 | 461 | const Bar = reflect({ 462 | view: Foo, 463 | bind: { 464 | foo: 'foo', 465 | bar: 42, 466 | }, 467 | hooks: { mounted, unmounted }, 468 | }); 469 | } 470 | 471 | // Edge-case: Mantine Button weird polymorphic factory 472 | { 473 | const ReflectedManitneButton = reflect({ 474 | view: Button<'button'>, 475 | bind: { 476 | children: 'foo', 477 | size: 'md', 478 | onClick: (e) => { 479 | expectType(e.clientX); 480 | }, 481 | }, 482 | }); 483 | 484 | const ReflectedManitneButtonBad = reflect({ 485 | view: Button<'button'>, 486 | bind: { 487 | children: 'foo', 488 | // @ts-expect-error 489 | size: 42, 490 | onClick: (e) => { 491 | expectType(e.clientX); 492 | }, 493 | }, 494 | }); 495 | 496 | { 499 | expectType(e.clientX); 500 | }} 501 | />; 502 | } 503 | 504 | // Edge-case (BROKEN): Mantine Button weird polymorphic factory 505 | // without explicit type argument 506 | // 507 | // This test is failing - it is left here for future reference, in case if there is a way to fix it 508 | // If you use a Mantine polymorphic components or anything similiar - check test above for a currently working solution 509 | { 510 | const ReflectedManitneButton = reflect({ 511 | view: Button, 512 | bind: { 513 | children: 'foo', 514 | // @ts-expect-error 515 | onClick: (e) => { 516 | expectType(e.clientX); 517 | }, 518 | }, 519 | }); 520 | 521 | { 526 | expectType(e.clientX); 527 | }} 528 | />; 529 | } 530 | 531 | // edge-case: polymorphic props 532 | { 533 | interface CommonProps { 534 | inline?: boolean; 535 | progress?: boolean; 536 | enabledOnProgress?: boolean; 537 | floating?: boolean; 538 | showSpinnerIcon?: boolean; 539 | onBright?: boolean; 540 | } 541 | type HTMLButtonProps = ButtonHTMLAttributes; 542 | interface ButtonButtonProps extends CommonProps, Omit { 543 | tag?: 'button'; 544 | href?: never; 545 | } 546 | type HTMLAnchorProps = AnchorHTMLAttributes; 547 | interface AnchorButtonProps extends CommonProps, HTMLAnchorProps { 548 | tag?: 'a'; 549 | } 550 | type HTMLLabelProps = LabelHTMLAttributes; 551 | interface LabelButtonProps extends CommonProps, HTMLLabelProps { 552 | tag?: 'label'; 553 | disabled?: boolean; 554 | } 555 | type ButtonProps = ButtonButtonProps | AnchorButtonProps | LabelButtonProps; 556 | 557 | const TestButton = (props: ButtonProps) => { 558 | return null; 559 | }; 560 | 561 | const ReflectedTestButton1 = reflect({ 562 | view: TestButton, 563 | bind: { 564 | inline: true, 565 | progress: true, 566 | tag: 'a', 567 | href: 'test', 568 | }, 569 | }); 570 | const ReflectedTestButton2 = reflect({ 571 | view: TestButton, 572 | // @ts-expect-error 573 | bind: { 574 | inline: true, 575 | progress: true, 576 | tag: 'button', 577 | href: 'test', 578 | }, 579 | }); 580 | } 581 | -------------------------------------------------------------------------------- /src/no-ssr/list.test.tsx: -------------------------------------------------------------------------------- 1 | import { list } from '@effector/reflect'; 2 | import { render } from '@testing-library/react'; 3 | import { allSettled, createEffect, createEvent, createStore, fork } from 'effector'; 4 | import { Provider, useStore } from 'effector-react'; 5 | import React, { FC, memo } from 'react'; 6 | import { act } from 'react-dom/test-utils'; 7 | 8 | const List: FC = (props) => { 9 | return
      {props.children}
    ; 10 | }; 11 | 12 | const ListItem: FC<{ title: string; prefix?: string }> = (props) => { 13 | return ( 14 |
  • {`${ 15 | props.prefix || '' 16 | }${props.title}`}
  • 17 | ); 18 | }; 19 | 20 | test('relfect-list: renders list from store', async () => { 21 | const $todos = createStore<{ title: string; body: string }[]>([ 22 | { title: 'Buy milk', body: 'Text' }, 23 | { title: 'Clean room', body: 'Text 2' }, 24 | { title: 'Do homework', body: 'Text 3' }, 25 | ]); 26 | 27 | const Items = list({ 28 | source: $todos, 29 | view: ListItem, 30 | bind: {}, 31 | mapItem: { 32 | title: (todo) => todo.title, 33 | }, 34 | }); 35 | 36 | const container = render( 37 | 38 | 39 | , 40 | ); 41 | 42 | const renderedIds = container 43 | .getAllByRole('listitem') 44 | .map((item) => item.dataset.testid); 45 | 46 | expect(renderedIds).toEqual($todos.getState().map((todo) => todo.title)); 47 | }); 48 | 49 | test('relfect-list: reflect hooks called once for every item', async () => { 50 | const $todos = createStore<{ title: string; body: string }[]>([ 51 | { title: 'Buy milk', body: 'Text' }, 52 | { title: 'Clean room', body: 'Text 2' }, 53 | { title: 'Do homework', body: 'Text 3' }, 54 | ]); 55 | 56 | const mounted = createEvent(); 57 | 58 | const fn = vi.fn(() => {}); 59 | 60 | mounted.watch(fn); 61 | 62 | const unmounted = createEvent(); 63 | 64 | const unfn = vi.fn(() => {}); 65 | 66 | mounted.watch(unfn); 67 | 68 | const Items = list({ 69 | source: $todos, 70 | view: ListItem, 71 | bind: {}, 72 | hooks: { mounted, unmounted }, 73 | mapItem: { 74 | title: (todo) => todo.title, 75 | }, 76 | }); 77 | 78 | const container = render( 79 | 80 | 81 | , 82 | ); 83 | 84 | expect(fn.mock.calls.length).toBe($todos.getState().length); 85 | 86 | container.unmount(); 87 | 88 | expect(unfn.mock.calls.length).toBe($todos.getState().length); 89 | }); 90 | 91 | test('reflect-list: rerenders on list changes', async () => { 92 | const addTodo = createEvent<{ title: string; body: string }>(); 93 | const removeTodo = createEvent(); 94 | const $todos = createStore<{ title: string; body: string }[]>([ 95 | { title: 'Buy milk', body: 'Text' }, 96 | { title: 'Clean room', body: 'Text 2' }, 97 | { title: 'Do homework', body: 'Text 3' }, 98 | ]); 99 | 100 | $todos 101 | .on(addTodo, (todos, next) => todos.concat(next)) 102 | .on(removeTodo, (todos, toRemove) => 103 | todos.filter((todo) => todo.title !== toRemove), 104 | ); 105 | 106 | const Items = list({ 107 | source: $todos, 108 | view: ListItem, 109 | bind: {}, 110 | mapItem: { 111 | title: (todo) => todo.title, 112 | }, 113 | }); 114 | 115 | const container = render( 116 | 117 | 118 | , 119 | ); 120 | 121 | expect( 122 | container.getAllByRole('listitem').map((item) => item.dataset.testid), 123 | ).toEqual($todos.getState().map((todo) => todo.title)); 124 | 125 | act(() => { 126 | addTodo({ title: 'Write tests', body: 'Text 4' }); 127 | }); 128 | 129 | expect( 130 | container.getAllByRole('listitem').map((item) => item.dataset.testid), 131 | ).toEqual($todos.getState().map((todo) => todo.title)); 132 | 133 | act(() => { 134 | removeTodo('Clean room'); 135 | }); 136 | 137 | expect( 138 | container.getAllByRole('listitem').map((item) => item.dataset.testid), 139 | ).toEqual($todos.getState().map((todo) => todo.title)); 140 | }); 141 | 142 | test('reflect-list: bind and mapItem optional, if source type matches view props', async () => { 143 | const addTodo = createEvent<{ title: string; body: string }>(); 144 | const removeTodo = createEvent(); 145 | const $todos = createStore<{ title: string; body: string }[]>([ 146 | { title: 'Buy milk', body: 'Text' }, 147 | { title: 'Clean room', body: 'Text 2' }, 148 | { title: 'Do homework', body: 'Text 3' }, 149 | ]); 150 | 151 | $todos 152 | .on(addTodo, (todos, next) => todos.concat(next)) 153 | .on(removeTodo, (todos, toRemove) => 154 | todos.filter((todo) => todo.title !== toRemove), 155 | ); 156 | 157 | const Items = list({ 158 | source: $todos, 159 | view: ListItem, 160 | }); 161 | 162 | const container = render( 163 | 164 | 165 | , 166 | ); 167 | 168 | expect( 169 | container.getAllByRole('listitem').map((item) => item.dataset.testid), 170 | ).toEqual($todos.getState().map((todo) => todo.title)); 171 | 172 | act(() => { 173 | addTodo({ title: 'Write tests', body: 'Text 4' }); 174 | }); 175 | 176 | expect( 177 | container.getAllByRole('listitem').map((item) => item.dataset.testid), 178 | ).toEqual($todos.getState().map((todo) => todo.title)); 179 | 180 | act(() => { 181 | removeTodo('Clean room'); 182 | }); 183 | 184 | expect( 185 | container.getAllByRole('listitem').map((item) => item.dataset.testid), 186 | ).toEqual($todos.getState().map((todo) => todo.title)); 187 | }); 188 | 189 | test('reflect-list: mapItem optional, if not needed', async () => { 190 | const addTodo = createEvent<{ title: string; body: string }>(); 191 | const removeTodo = createEvent(); 192 | const $todos = createStore<{ title: string; body: string }[]>([ 193 | { title: 'Buy milk', body: 'Text' }, 194 | { title: 'Clean room', body: 'Text 2' }, 195 | { title: 'Do homework', body: 'Text 3' }, 196 | ]); 197 | const $prefix = createStore('Pre:'); 198 | 199 | $todos 200 | .on(addTodo, (todos, next) => todos.concat(next)) 201 | .on(removeTodo, (todos, toRemove) => 202 | todos.filter((todo) => todo.title !== toRemove), 203 | ); 204 | 205 | const Items = list({ 206 | source: $todos, 207 | bind: { 208 | prefix: $prefix, 209 | }, 210 | view: ListItem, 211 | }); 212 | 213 | const container = render( 214 | 215 | 216 | , 217 | ); 218 | 219 | expect( 220 | container.getAllByRole('listitem').map((item) => item.dataset.testid), 221 | ).toEqual($todos.getState().map((todo) => todo.title)); 222 | expect( 223 | container.getAllByRole('listitem').map((item) => item.dataset.prefix), 224 | ).toEqual($todos.getState().map(() => $prefix.getState())); 225 | 226 | act(() => { 227 | addTodo({ title: 'Write tests', body: 'Text 4' }); 228 | }); 229 | 230 | expect( 231 | container.getAllByRole('listitem').map((item) => item.dataset.testid), 232 | ).toEqual($todos.getState().map((todo) => todo.title)); 233 | expect( 234 | container.getAllByRole('listitem').map((item) => item.dataset.prefix), 235 | ).toEqual($todos.getState().map(() => $prefix.getState())); 236 | 237 | act(() => { 238 | removeTodo('Clean room'); 239 | }); 240 | 241 | expect( 242 | container.getAllByRole('listitem').map((item) => item.dataset.testid), 243 | ).toEqual($todos.getState().map((todo) => todo.title)); 244 | expect( 245 | container.getAllByRole('listitem').map((item) => item.dataset.prefix), 246 | ).toEqual($todos.getState().map(() => $prefix.getState())); 247 | }); 248 | 249 | test('reflect-list: bind is optional if not needed', async () => { 250 | const addTodo = createEvent<{ title: string; body: string }>(); 251 | const removeTodo = createEvent(); 252 | const $todos = createStore<{ title: string; body: string }[]>([ 253 | { title: 'Buy milk', body: 'Text' }, 254 | { title: 'Clean room', body: 'Text 2' }, 255 | { title: 'Do homework', body: 'Text 3' }, 256 | ]); 257 | 258 | $todos 259 | .on(addTodo, (todos, next) => todos.concat(next)) 260 | .on(removeTodo, (todos, toRemove) => 261 | todos.filter((todo) => todo.title !== toRemove), 262 | ); 263 | 264 | const Items = list({ 265 | source: $todos, 266 | view: ListItem, 267 | mapItem: { 268 | title: (item) => item.title, 269 | }, 270 | }); 271 | 272 | const container = render( 273 | 274 | 275 | , 276 | ); 277 | 278 | expect( 279 | container.getAllByRole('listitem').map((item) => item.dataset.testid), 280 | ).toEqual($todos.getState().map((todo) => todo.title)); 281 | 282 | act(() => { 283 | addTodo({ title: 'Write tests', body: 'Text 4' }); 284 | }); 285 | 286 | expect( 287 | container.getAllByRole('listitem').map((item) => item.dataset.testid), 288 | ).toEqual($todos.getState().map((todo) => todo.title)); 289 | 290 | act(() => { 291 | removeTodo('Clean room'); 292 | }); 293 | 294 | expect( 295 | container.getAllByRole('listitem').map((item) => item.dataset.testid), 296 | ).toEqual($todos.getState().map((todo) => todo.title)); 297 | }); 298 | 299 | test('reflect-list: reflect binds props to every item in the list', async () => { 300 | const $todos = createStore<{ title: string; body: string }[]>([ 301 | { title: 'Buy milk', body: 'Text' }, 302 | { title: 'Clean room', body: 'Text 2' }, 303 | { title: 'Do homework', body: 'Text 3' }, 304 | ]); 305 | 306 | const $prefix = createStore(''); 307 | const prefix = createEvent(); 308 | 309 | $prefix.on(prefix, (_, next) => next); 310 | 311 | const Items = list({ 312 | source: $todos, 313 | view: ListItem, 314 | bind: { 315 | prefix: $prefix, 316 | }, 317 | mapItem: { 318 | title: (todo) => todo.title, 319 | }, 320 | }); 321 | 322 | const container = render( 323 | 324 | 325 | , 326 | ); 327 | 328 | expect( 329 | container.getAllByRole('listitem').map((item) => item.dataset.prefix), 330 | ).toEqual($todos.getState().map(() => $prefix.getState())); 331 | 332 | act(() => { 333 | prefix('Task: '); 334 | }); 335 | 336 | expect( 337 | container.getAllByRole('listitem').map((item) => item.dataset.prefix), 338 | ).toEqual($todos.getState().map(() => $prefix.getState())); 339 | 340 | act(() => { 341 | prefix(''); 342 | }); 343 | 344 | expect( 345 | container.getAllByRole('listitem').map((item) => item.dataset.prefix), 346 | ).toEqual($todos.getState().map(() => $prefix.getState())); 347 | }); 348 | 349 | interface MemberProps { 350 | id: number; 351 | name: string; 352 | } 353 | 354 | const Member: FC = (props) => { 355 | const { name, id } = props; 356 | 357 | return
  • {name}
  • ; 358 | }; 359 | 360 | test('reflect-list: getKey option', async () => { 361 | const fn = vi.fn(); 362 | const fn2 = vi.fn(); 363 | const renameUser = createEvent<{ id: number; name: string }>(); 364 | const removeUser = createEvent(); 365 | const sortById = createEvent(); 366 | const $members = createStore([ 367 | { name: 'alice', id: 1 }, 368 | { name: 'bob', id: 3 }, 369 | { name: 'carol', id: 2 }, 370 | ]) 371 | .on(renameUser, (list, { id, name }) => 372 | list.map((e) => (e.id === id ? { id, name } : e)), 373 | ) 374 | .on(removeUser, (list, id) => list.filter((e) => e.id !== id)) 375 | .on(sortById, (list) => [...list].sort((a, b) => a.id - b.id)); 376 | 377 | // plain 378 | const PlainMember = memo((props: MemberProps) => { 379 | fn2(props); 380 | return ; 381 | }); 382 | 383 | const PlainMemberList = () => { 384 | return ( 385 |
      386 | {useStore($members).map((props) => ( 387 | 388 | ))} 389 |
    390 | ); 391 | }; 392 | 393 | // reflect 394 | const ReflectMember: FC = (props) => { 395 | fn(props); 396 | return ; 397 | }; 398 | const ReflectList: FC = (props) =>
      {props.children}
    ; 399 | const Members = list({ 400 | source: $members, 401 | view: ReflectMember, 402 | bind: {}, 403 | mapItem: { 404 | id: (member) => member.id, 405 | name: (member) => member.name, 406 | }, 407 | getKey: (item) => item.id, 408 | }); 409 | 410 | const App = () => ( 411 | <> 412 | 413 | 414 | 415 | 416 | 417 | ); 418 | 419 | const container = render(); 420 | 421 | // first check 422 | expect( 423 | Array.from(container.getByTestId('plain').querySelectorAll('li')).map((item) => 424 | Number(item.dataset.testid), 425 | ), 426 | ).toEqual($members.getState().map((member) => member.id)); 427 | expect( 428 | Array.from(container.getByTestId('reflect').querySelectorAll('li')).map((item) => 429 | Number(item.dataset.testid), 430 | ), 431 | ).toEqual($members.getState().map((member) => member.id)); 432 | 433 | expect(fn.mock.calls.map(([arg]) => arg)).toEqual( 434 | fn2.mock.calls.map(([arg]) => arg), 435 | ); 436 | 437 | act(() => { 438 | sortById(); 439 | }); 440 | 441 | // second check 442 | expect( 443 | Array.from(container.getByTestId('plain').querySelectorAll('li')).map((item) => 444 | Number(item.dataset.testid), 445 | ), 446 | ).toEqual($members.getState().map((member) => member.id)); 447 | expect( 448 | Array.from(container.getByTestId('reflect').querySelectorAll('li')).map((item) => 449 | Number(item.dataset.testid), 450 | ), 451 | ).toEqual($members.getState().map((member) => member.id)); 452 | 453 | expect(fn.mock.calls.map(([arg]) => arg)).toEqual( 454 | fn2.mock.calls.map(([arg]) => arg), 455 | ); 456 | 457 | act(() => { 458 | renameUser({ id: 2, name: 'charlie' }); 459 | }); 460 | 461 | // third check 462 | expect( 463 | Array.from(container.getByTestId('plain').querySelectorAll('li')).map((item) => 464 | Number(item.dataset.testid), 465 | ), 466 | ).toEqual($members.getState().map((member) => member.id)); 467 | expect( 468 | Array.from(container.getByTestId('reflect').querySelectorAll('li')).map((item) => 469 | Number(item.dataset.testid), 470 | ), 471 | ).toEqual($members.getState().map((member) => member.id)); 472 | 473 | expect(fn.mock.calls.map(([arg]) => arg)).toEqual( 474 | fn2.mock.calls.map(([arg]) => arg), 475 | ); 476 | 477 | act(() => { 478 | removeUser(2); 479 | }); 480 | 481 | // last check 482 | expect( 483 | Array.from(container.getByTestId('plain').querySelectorAll('li')).map((item) => 484 | Number(item.dataset.testid), 485 | ), 486 | ).toEqual($members.getState().map((member) => member.id)); 487 | expect( 488 | Array.from(container.getByTestId('reflect').querySelectorAll('li')).map((item) => 489 | Number(item.dataset.testid), 490 | ), 491 | ).toEqual($members.getState().map((member) => member.id)); 492 | 493 | expect(fn.mock.calls.map(([arg]) => arg)).toEqual( 494 | fn2.mock.calls.map(([arg]) => arg), 495 | ); 496 | }); 497 | 498 | test('scoped callback support in mapItem', async () => { 499 | const sleepFx = createEffect( 500 | async (ms: number) => new Promise((rs) => setTimeout(rs, ms)), 501 | ); 502 | let sendRender = (v: string) => {}; 503 | const Input = (props: { 504 | value: string; 505 | onChange: (_event: string) => Promise; 506 | }) => { 507 | const [render, setRender] = React.useState(null); 508 | React.useLayoutEffect(() => { 509 | if (render) { 510 | props.onChange(render); 511 | } 512 | }, [render]); 513 | sendRender = setRender; 514 | return ; 515 | }; 516 | 517 | const $names = createStore(['name']); 518 | const $name = createStore(''); 519 | const changeName = createEvent(); 520 | $name.on(changeName, (_, next) => next); 521 | 522 | const Names = list({ 523 | source: $names, 524 | view: Input, 525 | bind: { 526 | value: $name, 527 | }, 528 | mapItem: { 529 | onChange: (_name) => async (event: string) => { 530 | await sleepFx(100); 531 | changeName(event); 532 | }, 533 | }, 534 | }); 535 | 536 | const scope = fork(); 537 | render( 538 | 539 | 540 | , 541 | ); 542 | 543 | await act(async () => { 544 | sendRender('Bob'); 545 | }); 546 | await allSettled(scope); 547 | 548 | expect(scope.getState($name)).toBe('Bob'); 549 | expect($name.getState()).toBe(''); 550 | }); 551 | 552 | describe('useUnitConfig', () => { 553 | test('useUnit config should be passed to underlying useUnit', () => { 554 | expect(() => { 555 | const Test = list({ 556 | view: () => null, 557 | source: createStore([42]), 558 | useUnitConfig: { 559 | forceScope: true, 560 | }, 561 | }); 562 | render(); 563 | }).toThrowErrorMatchingInlineSnapshot( 564 | `[Error: No scope found, consider adding to app root]`, 565 | ); 566 | }); 567 | }); 568 | -------------------------------------------------------------------------------- /src/no-ssr/reflect.test.tsx: -------------------------------------------------------------------------------- 1 | import { fromTag, reflect } from '@effector/reflect'; 2 | import { render } from '@testing-library/react'; 3 | import userEvent from '@testing-library/user-event'; 4 | import { 5 | allSettled, 6 | createEffect, 7 | createEvent, 8 | createStore, 9 | fork, 10 | restore, 11 | } from 'effector'; 12 | import { Provider } from 'effector-react'; 13 | import React, { ChangeEvent, FC, InputHTMLAttributes } from 'react'; 14 | import { act } from 'react-dom/test-utils'; 15 | 16 | // Example1 (InputCustom) 17 | const InputCustom: FC<{ 18 | value: string | number | string[]; 19 | onChange(value: string): void; 20 | testId: string; 21 | placeholder?: string; 22 | }> = (props) => { 23 | return ( 24 | props.onChange(event.currentTarget.value)} 29 | /> 30 | ); 31 | }; 32 | 33 | test('InputCustom', async () => { 34 | const change = createEvent(); 35 | const $name = restore(change, ''); 36 | 37 | const Name = reflect({ 38 | view: InputCustom, 39 | bind: { value: $name, onChange: change }, 40 | }); 41 | 42 | const container = render(); 43 | 44 | expect($name.getState()).toBe(''); 45 | await userEvent.type(container.getByTestId('name'), 'Bob'); 46 | expect($name.getState()).toBe('Bob'); 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 change = createEvent(); 54 | const $name = createStore(''); 55 | 56 | $name.on(change, (_, next) => next); 57 | 58 | const Name = reflect({ 59 | view: InputCustom, 60 | bind: { name: $name, onChange: change }, 61 | }); 62 | 63 | const container = render(); 64 | 65 | expect($name.getState()).toBe(''); 66 | await userEvent.type(container.getByTestId('name'), 'Bob'); 67 | expect($name.getState()).toBe('Aliseb'); 68 | 69 | const inputName = container.container.firstChild as HTMLInputElement; 70 | expect(inputName.value).toBe('Alise'); 71 | }); 72 | 73 | // Example 2 (InputBase) 74 | type InputBaseProps = InputHTMLAttributes; 75 | const InputBase: FC = (props) => { 76 | return ; 77 | }; 78 | 79 | test('InputBase', async () => { 80 | const changeName = createEvent(); 81 | const $name = restore(changeName, ''); 82 | 83 | const inputChanged = (event: ChangeEvent) => { 84 | return event.currentTarget.value; 85 | }; 86 | 87 | const Name = reflect({ 88 | view: InputBase, 89 | bind: { 90 | value: $name, 91 | onChange: changeName.prepend(inputChanged), 92 | }, 93 | }); 94 | 95 | const changeAge = createEvent(); 96 | const $age = restore(changeAge, 0); 97 | const Age = reflect({ 98 | view: InputBase, 99 | bind: { 100 | value: $age, 101 | onChange: changeAge.prepend(parseInt).prepend(inputChanged), 102 | }, 103 | }); 104 | 105 | const container = render( 106 | <> 107 | 108 | 109 | , 110 | ); 111 | 112 | expect($name.getState()).toBe(''); 113 | await userEvent.type(container.getByTestId('name'), 'Bob'); 114 | expect($name.getState()).toBe('Bob'); 115 | 116 | expect($age.getState()).toBe(0); 117 | await userEvent.type(container.getByTestId('age'), '25'); 118 | expect($age.getState()).toBe(25); 119 | 120 | const inputName = container.getByTestId('name') as HTMLInputElement; 121 | expect(inputName.value).toBe('Bob'); 122 | 123 | const inputAge = container.getByTestId('age') as HTMLInputElement; 124 | expect(inputAge.value).toBe('25'); 125 | }); 126 | 127 | test('component inside', async () => { 128 | const changeName = createEvent(); 129 | const $name = restore(changeName, ''); 130 | 131 | const Name = reflect({ 132 | view: (props: { 133 | value: string; 134 | onChange: (_event: ChangeEvent) => void; 135 | }) => { 136 | return ( 137 | 138 | ); 139 | }, 140 | bind: { 141 | value: $name, 142 | onChange: changeName.prepend((event) => event.currentTarget.value), 143 | }, 144 | }); 145 | 146 | const container = render(); 147 | 148 | expect($name.getState()).toBe(''); 149 | await userEvent.type(container.getByTestId('name'), 'Bob'); 150 | expect($name.getState()).toBe('Bob'); 151 | 152 | const inputName = container.getByTestId('name') as HTMLInputElement; 153 | expect(inputName.value).toBe('Bob'); 154 | }); 155 | 156 | test('forwardRef', async () => { 157 | const Name = reflect({ 158 | view: React.forwardRef((props, ref: React.ForwardedRef) => { 159 | return ; 160 | }), 161 | bind: {}, 162 | }); 163 | 164 | const ref = React.createRef(); 165 | 166 | const container = render(); 167 | expect(container.getByTestId('name')).toBe(ref.current); 168 | }); 169 | 170 | describe('plain callbacks with scopeBind under the hood', () => { 171 | test('sync callback in bind', async () => { 172 | let sendRender = (v: string) => {}; 173 | const Input = (props: { value: string; onChange: (_event: string) => void }) => { 174 | const [render, setRender] = React.useState(null); 175 | React.useLayoutEffect(() => { 176 | if (render) { 177 | props.onChange(render); 178 | } 179 | }, [render]); 180 | sendRender = setRender; 181 | return ; 182 | }; 183 | 184 | const $name = createStore(''); 185 | const changeName = createEvent(); 186 | $name.on(changeName, (_, next) => next); 187 | 188 | const Name = reflect({ 189 | view: Input, 190 | bind: { 191 | value: $name, 192 | onChange: (v) => changeName(v), 193 | }, 194 | }); 195 | 196 | render(); 197 | 198 | await act(() => { 199 | sendRender('Bob'); 200 | }); 201 | 202 | expect($name.getState()).toBe('Bob'); 203 | }); 204 | 205 | test('sync callback in bind (scope)', async () => { 206 | let sendRender = (v: string) => {}; 207 | const Input = (props: { value: string; onChange: (_event: string) => void }) => { 208 | const [render, setRender] = React.useState(null); 209 | React.useLayoutEffect(() => { 210 | if (render) { 211 | props.onChange(render); 212 | } 213 | }, [render]); 214 | sendRender = setRender; 215 | return ; 216 | }; 217 | 218 | const $name = createStore(''); 219 | const changeName = createEvent(); 220 | $name.on(changeName, (_, next) => next); 221 | 222 | const Name = reflect({ 223 | view: Input, 224 | bind: { 225 | value: $name, 226 | onChange: (v) => changeName(v), 227 | }, 228 | }); 229 | 230 | const scope = fork(); 231 | 232 | render( 233 | 234 | 235 | , 236 | ); 237 | 238 | await act(() => { 239 | sendRender('Bob'); 240 | }); 241 | 242 | expect(scope.getState($name)).toBe('Bob'); 243 | expect($name.getState()).toBe(''); 244 | }); 245 | 246 | test('async callback in bind (scope)', async () => { 247 | const sleepFx = createEffect( 248 | async (ms: number) => new Promise((rs) => setTimeout(rs, ms)), 249 | ); 250 | let sendRender = (v: string) => {}; 251 | const Input = (props: { 252 | value: string; 253 | onChange: (_event: string) => Promise; 254 | }) => { 255 | const [render, setRender] = React.useState(null); 256 | React.useLayoutEffect(() => { 257 | if (render) { 258 | props.onChange(render); 259 | } 260 | }, [render]); 261 | sendRender = setRender; 262 | return ; 263 | }; 264 | 265 | const $name = createStore(''); 266 | const changeName = createEvent(); 267 | $name.on(changeName, (_, next) => next); 268 | 269 | const Name = reflect({ 270 | view: Input, 271 | bind: { 272 | value: $name, 273 | onChange: async (v) => { 274 | await sleepFx(1); 275 | changeName(v); 276 | }, 277 | }, 278 | }); 279 | 280 | const scope = fork(); 281 | 282 | render( 283 | 284 | 285 | , 286 | ); 287 | 288 | await act(() => { 289 | sendRender('Bob'); 290 | }); 291 | 292 | await allSettled(scope); 293 | 294 | expect(scope.getState($name)).toBe('Bob'); 295 | expect($name.getState()).toBe(''); 296 | }); 297 | 298 | test('render props work', async () => { 299 | const RenderComp = (props: { 300 | prefix: string; 301 | renderMe: (props: { value: string }) => React.ReactNode; 302 | }) => { 303 | return
    {props.renderMe({ value: `${props.prefix}: text` })}
    ; 304 | }; 305 | const Text = (props: { value: string }) => { 306 | return {props.value}; 307 | }; 308 | 309 | const ReflectedRender = reflect({ 310 | view: RenderComp, 311 | bind: { 312 | prefix: createStore('Hello'), 313 | renderMe: Text, 314 | }, 315 | }); 316 | 317 | const scope = fork(); 318 | 319 | const container = render( 320 | 321 | 322 | , 323 | ); 324 | 325 | expect(container.container.firstChild).toMatchInlineSnapshot(` 326 |
    327 | 328 | Hello: text 329 | 330 |
    331 | `); 332 | }); 333 | }); 334 | 335 | describe('hooks', () => { 336 | describe('mounted', () => { 337 | test('callback', () => { 338 | const changeName = createEvent(); 339 | const $name = restore(changeName, ''); 340 | 341 | const mounted = vi.fn(() => {}); 342 | 343 | const Name = reflect({ 344 | view: InputBase, 345 | bind: { 346 | value: $name, 347 | onChange: changeName.prepend((event) => event.currentTarget.value), 348 | }, 349 | hooks: { mounted }, 350 | }); 351 | 352 | render(); 353 | 354 | expect(mounted.mock.calls.length).toBe(1); 355 | }); 356 | 357 | test('callback in scope', () => { 358 | const mounted = createEvent(); 359 | const $isMounted = createStore(false).on(mounted, () => true); 360 | 361 | const scope = fork(); 362 | 363 | const Name = reflect({ 364 | view: InputBase, 365 | bind: {}, 366 | hooks: { mounted: () => mounted() }, 367 | }); 368 | 369 | render( 370 | 371 | 372 | , 373 | ); 374 | 375 | expect($isMounted.getState()).toBe(false); 376 | expect(scope.getState($isMounted)).toBe(true); 377 | }); 378 | 379 | test('callback with props', () => { 380 | const mounted = createEvent(); 381 | const $lastProps = restore(mounted, null); 382 | 383 | const $value = createStore('test'); 384 | 385 | const scope = fork(); 386 | 387 | const Name = reflect({ 388 | view: InputBase, 389 | bind: { 390 | value: $value, 391 | }, 392 | hooks: { 393 | mounted: (props: InputBaseProps) => mounted(props), 394 | }, 395 | }); 396 | 397 | render( 398 | 399 | 400 | , 401 | ); 402 | 403 | expect($lastProps.getState()).toBeNull(); 404 | expect(scope.getState($lastProps)).toEqual({ 405 | value: 'test', 406 | 'data-testid': 'name', 407 | }); 408 | }); 409 | 410 | test('event', () => { 411 | const changeName = createEvent(); 412 | const $name = restore(changeName, ''); 413 | const mounted = createEvent(); 414 | 415 | const fn = vi.fn(() => {}); 416 | 417 | mounted.watch(fn); 418 | 419 | const Name = reflect({ 420 | view: InputBase, 421 | bind: { 422 | value: $name, 423 | onChange: changeName.prepend((event) => event.currentTarget.value), 424 | }, 425 | hooks: { mounted }, 426 | }); 427 | 428 | render(); 429 | 430 | expect(fn.mock.calls.length).toBe(1); 431 | }); 432 | 433 | test('event in scope', () => { 434 | const mounted = createEvent(); 435 | const $isMounted = createStore(false).on(mounted, () => true); 436 | 437 | const scope = fork(); 438 | 439 | const Name = reflect({ 440 | view: InputBase, 441 | bind: {}, 442 | hooks: { mounted }, 443 | }); 444 | 445 | render( 446 | 447 | 448 | , 449 | ); 450 | 451 | expect($isMounted.getState()).toBe(false); 452 | expect(scope.getState($isMounted)).toBe(true); 453 | }); 454 | 455 | test('event with props', () => { 456 | const mounted = createEvent(); 457 | const $lastProps = restore(mounted, null); 458 | 459 | const $value = createStore('test'); 460 | 461 | const scope = fork(); 462 | 463 | const Name = reflect({ 464 | view: InputBase, 465 | bind: { 466 | value: $value, 467 | }, 468 | hooks: { mounted }, 469 | }); 470 | 471 | render( 472 | 473 | 474 | , 475 | ); 476 | 477 | expect($lastProps.getState()).toBeNull(); 478 | expect(scope.getState($lastProps)).toEqual({ 479 | value: 'test', 480 | 'data-testid': 'name', 481 | }); 482 | }); 483 | }); 484 | 485 | describe('unmounted', () => { 486 | const changeVisible = createEffect({ 487 | handler: () => {}, 488 | }); 489 | const $visible = restore( 490 | changeVisible.finally.map(({ params }) => params), 491 | true, 492 | ); 493 | 494 | const Branch = reflect<{ visible: boolean }>({ 495 | view: ({ visible, children }) => (visible ? <>{children} : null), 496 | bind: { visible: $visible }, 497 | }); 498 | 499 | beforeEach(() => { 500 | act(() => { 501 | changeVisible(true); 502 | }); 503 | }); 504 | 505 | test('callback', () => { 506 | const changeName = createEvent(); 507 | const $name = restore(changeName, ''); 508 | 509 | const unmounted = vi.fn(() => {}); 510 | 511 | const Name = reflect({ 512 | view: InputBase, 513 | bind: { 514 | value: $name, 515 | onChange: changeName.prepend((event) => event.currentTarget.value), 516 | }, 517 | hooks: { unmounted }, 518 | }); 519 | 520 | render(, { wrapper: Branch }); 521 | 522 | act(() => { 523 | changeVisible(false); 524 | }); 525 | 526 | expect(unmounted.mock.calls.length).toBe(1); 527 | }); 528 | 529 | test('event', () => { 530 | const changeName = createEvent(); 531 | const $name = restore(changeName, ''); 532 | 533 | const unmounted = createEvent(); 534 | const fn = vi.fn(() => {}); 535 | 536 | unmounted.watch(fn); 537 | 538 | const Name = reflect({ 539 | view: InputBase, 540 | bind: { 541 | value: $name, 542 | onChange: changeName.prepend((event) => event.currentTarget.value), 543 | }, 544 | hooks: { unmounted }, 545 | }); 546 | 547 | render(, { wrapper: Branch }); 548 | 549 | act(() => { 550 | changeVisible(false); 551 | }); 552 | 553 | expect(fn.mock.calls.length).toBe(1); 554 | }); 555 | }); 556 | }); 557 | 558 | describe('fromTag helper', () => { 559 | test('Basic usage work', async () => { 560 | const changed = createEvent(); 561 | const $fromHandler = createStore(null).on(changed, (_, next) => next); 562 | const $type = createStore('hidden'); 563 | 564 | const DomInput = fromTag('input'); 565 | 566 | const Input = reflect({ 567 | view: DomInput, 568 | bind: { 569 | type: $type, 570 | onChange: changed.prepend((event) => event), 571 | 'data-testid': 'test-input', 572 | }, 573 | }); 574 | 575 | const scopeText = fork({ 576 | values: [[$type, 'text']], 577 | }); 578 | const scopeEmail = fork({ 579 | values: [[$type, 'email']], 580 | }); 581 | 582 | const body = render( 583 | 584 | 585 | , 586 | ); 587 | 588 | expect((body.container.firstChild as any).type).toBe('text'); 589 | 590 | await userEvent.type(body.getByTestId('test-input'), 'bob'); 591 | 592 | expect(scopeText.getState($fromHandler).target.value).toBe('bob'); 593 | 594 | const body2 = render( 595 | 596 | 597 | , 598 | ); 599 | 600 | expect((body2.container.firstChild as any).type).toBe('email'); 601 | }); 602 | }); 603 | 604 | describe('useUnitConfig', () => { 605 | test('useUnit config should be passed to underlying useUnit', () => { 606 | expect(() => { 607 | const Test = reflect({ 608 | view: () => null, 609 | bind: {}, 610 | useUnitConfig: { 611 | forceScope: true, 612 | }, 613 | }); 614 | render(); 615 | }).toThrowErrorMatchingInlineSnapshot( 616 | `[Error: No scope found, consider adding to app root]`, 617 | ); 618 | }); 619 | }); 620 | --------------------------------------------------------------------------------