├── .commitlintrc.json ├── .editorconfig ├── .eslintrc.json ├── .gitattributes ├── .github └── workflows │ ├── e2e.yml │ ├── lint-code.yml │ ├── lint-pr-title.yml │ ├── unittest.yml │ └── verify-and-release.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .releaserc.json ├── CODE_OF_CONDUCT.md ├── CODE_OF_CONDUCT.zh-Hans.md ├── LICENSE ├── README.md ├── README.zh-Hans.md ├── additional-configs ├── .jest-puppeteerrc.json ├── jest.d.ts ├── jestGlobalSetupE2E.ts ├── jestGlobalTeardownE2E.ts ├── jestSetupAfterEnvUnit.ts └── tsconfig.types.json ├── assets ├── 659f196c95dfb272d6648a89c5aedaf3e9a94a0d.gif └── fa668bd880860a5790b4a5bc0d1d0f40adebd47d.jpg ├── e2e ├── basic-use-in-different-formats.spec.ts ├── fixtures │ └── basic-use-in-different-formats │ │ ├── .eslintrc.json │ │ ├── global.d.ts │ │ ├── jest.config.js │ │ ├── obsolete-module-resolution │ │ ├── .gitignore │ │ ├── public │ │ │ └── index.browser-bundle-esm-by-default.html │ │ ├── scripts │ │ │ └── prepare.js │ │ ├── src │ │ │ ├── index.browser-bundle-cjs.ts │ │ │ ├── index.browser-bundle-esm-by-default.ts │ │ │ ├── index.browser-bundle-umd.ts │ │ │ └── index.test-import-cjs-by-default.ts │ │ ├── tsconfig.json │ │ └── webpack.merge.config.js │ │ ├── package.json │ │ ├── public │ │ ├── index.browser-bundle-cjs.html │ │ ├── index.browser-bundle-esm-by-default.html │ │ ├── index.browser-bundle-umd.html │ │ └── index.browser-plain-script-umd.html │ │ ├── src │ │ ├── appLogics.tsx │ │ ├── index.browser-bundle-cjs.ts │ │ ├── index.browser-bundle-esm-by-default.ts │ │ ├── index.browser-bundle-umd.ts │ │ ├── index.browser-plain-script-umd.js │ │ ├── index.node-import-cjs-by-default.mjs │ │ ├── index.node-require-cjs-by-default.js │ │ └── index.test-import-cjs-by-default.ts │ │ ├── tsconfig.json │ │ └── webpack.config.js └── helpers │ ├── constants.ts │ ├── index.ts │ ├── logger.ts │ ├── throwErr.ts │ └── waitFor.ts ├── examples └── select-item-to-edit │ ├── README.md │ ├── package.json │ ├── public │ └── index.html │ ├── src │ ├── App.tsx │ ├── components │ │ ├── ItemList.tsx │ │ ├── ItemListHeader.tsx │ │ └── TextEditorDialog.tsx │ ├── index.tsx │ ├── react-app-env.d.ts │ ├── reportWebVitals.ts │ ├── setupTests.ts │ └── states │ │ ├── ItemPanelState.ts │ │ └── TextEditorState.ts │ └── tsconfig.json ├── jest.config.js ├── package.json ├── rollup.config.mjs ├── src ├── Operate.test-d.tsx ├── Operate.test.tsx ├── Operate.ts ├── Snapshot.test.tsx ├── Snapshot.ts ├── Store.tsx ├── StoreHOC.test-d.tsx ├── StoreHOC.test.tsx ├── StoreProvider.test.tsx ├── index.ts ├── ssr.test.tsx ├── utils.test.ts └── utils.ts ├── tsconfig.json └── verdaccio ├── config.yml └── htpasswd /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"] 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [**/*] 4 | charset = utf-8 5 | indent_style = space 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": [ 4 | "eslint:recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "plugin:react/recommended", 7 | "plugin:react-hooks/recommended", 8 | "plugin:jest/recommended", 9 | "plugin:json/recommended-with-comments", 10 | "plugin:yaml/recommended", 11 | "prettier" 12 | ], 13 | "settings": { 14 | "react": { 15 | "version": "detect" 16 | } 17 | }, 18 | "env": { 19 | "browser": true, 20 | "node": true, 21 | "jest": true 22 | }, 23 | "globals": { 24 | "globalThis": true 25 | }, 26 | "rules": { 27 | "no-empty": "off", 28 | "@typescript-eslint/ban-types": "off", 29 | "@typescript-eslint/no-empty-function": "off", 30 | "@typescript-eslint/no-explicit-any": "off", 31 | "@typescript-eslint/no-inferrable-types": "off", 32 | "@typescript-eslint/no-var-requires": "off", 33 | "jest/expect-expect": "off" 34 | }, 35 | "overrides": [ 36 | { 37 | "files": ["**/*.d.ts?(x)"], 38 | "rules": { 39 | "no-var": "off", 40 | "@typescript-eslint/no-unused-vars": "off" 41 | } 42 | }, 43 | { 44 | "files": ["**/*.test-d.ts?(x)"], 45 | "rules": { 46 | "@typescript-eslint/ban-ts-comment": "off", 47 | "@typescript-eslint/no-unused-vars": "off" 48 | } 49 | } 50 | ], 51 | "ignorePatterns": [ 52 | "**/node_modules", 53 | "**/dist", 54 | "**/coverage", 55 | "**/examples", 56 | "verdaccio/storage", 57 | "verdaccio/htpasswd", 58 | "**/*.html", 59 | "**/*.md", 60 | "**/*.jpg", 61 | "**/*.gif", 62 | "**/LICENSE" 63 | ] 64 | } 65 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto eol=lf 3 | -------------------------------------------------------------------------------- /.github/workflows/e2e.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | branches: 4 | - main 5 | workflow_call: 6 | 7 | jobs: 8 | main: 9 | timeout-minutes: 30 10 | strategy: 11 | matrix: 12 | os: [ubuntu-latest, windows-latest] 13 | runs-on: ${{ matrix.os }} 14 | steps: 15 | - uses: actions/checkout@v3 16 | - uses: actions/setup-node@v3 17 | with: 18 | node-version: lts/* 19 | - run: npm i 20 | - run: npm run build-all 21 | - run: npm run e2e 22 | -------------------------------------------------------------------------------- /.github/workflows/lint-code.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | branches: 4 | - main 5 | workflow_call: 6 | 7 | jobs: 8 | main: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-node@v3 13 | with: 14 | node-version: lts/* 15 | - run: npm i 16 | - run: npm run lint-all 17 | -------------------------------------------------------------------------------- /.github/workflows/lint-pr-title.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | types: 4 | - opened 5 | - synchronize 6 | - reopened 7 | - edited 8 | branches: 9 | - main 10 | 11 | jobs: 12 | main: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | - uses: actions/setup-node@v3 17 | with: 18 | node-version: lts/* 19 | - run: npm i 20 | - run: echo '${{ github.event.pull_request.title }}' | npx -y @commitlint/cli@17 21 | -------------------------------------------------------------------------------- /.github/workflows/unittest.yml: -------------------------------------------------------------------------------- 1 | on: 2 | pull_request: 3 | branches: 4 | - main 5 | workflow_call: 6 | 7 | jobs: 8 | main: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-node@v3 13 | with: 14 | node-version: lts/* 15 | - run: npm i 16 | - run: npm run unittest -- --coverage 17 | - uses: codecov/codecov-action@v3 18 | with: 19 | token: ${{ secrets.CODECOV_TOKEN }} 20 | flags: unittest 21 | directory: ./coverage 22 | verbose: true 23 | fail_ci_if_error: true 24 | -------------------------------------------------------------------------------- /.github/workflows/verify-and-release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | - latest 6 | 7 | jobs: 8 | unittest: 9 | uses: ./.github/workflows/unittest.yml 10 | e2e: 11 | uses: ./.github/workflows/e2e.yml 12 | lint-code: 13 | uses: ./.github/workflows/lint-code.yml 14 | release: 15 | needs: 16 | - unittest 17 | - e2e 18 | - lint-code 19 | runs-on: ubuntu-latest 20 | permissions: 21 | # For what each permission scope represents, refer to: 22 | # https://docs.github.com/en/rest/overview/permissions-required-for-github-apps 23 | contents: write 24 | issues: write 25 | pull-requests: write 26 | steps: 27 | - uses: actions/checkout@v3 28 | with: 29 | fetch-depth: 0 30 | - uses: actions/setup-node@v3 31 | with: 32 | node-version: lts/* 33 | - run: npm i 34 | - run: npm run build-all 35 | - env: 36 | NPM_TOKEN: ${{secrets.SL_NPM_TOKEN}} 37 | GITHUB_TOKEN: ${{secrets.SL_GITHUB_TOKEN}} 38 | run: npx -y semantic-release@21 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vscode 3 | 4 | *.log* 5 | coverage 6 | 7 | dist 8 | build 9 | 10 | node_modules 11 | package-lock.json 12 | 13 | verdaccio/storage 14 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vscode 3 | 4 | *.log* 5 | coverage 6 | 7 | dist 8 | build 9 | 10 | node_modules 11 | package-lock.json 12 | 13 | verdaccio/storage 14 | verdaccio/htpasswd 15 | 16 | .* 17 | !.github 18 | !.*.json 19 | 20 | *.jpg 21 | *.gif 22 | LICENSE 23 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "auto", 3 | "printWidth": 100, 4 | "singleQuote": true, 5 | "importOrder": ["^node:", "", "^\\."], 6 | "importOrderCaseInsensitive": true, 7 | "importOrderSeparation": true, 8 | "importOrderSortSpecifiers": true 9 | } 10 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | { "name": "main", "channel": "beta", "prerelease": "beta" }, 4 | { "name": "latest", "channel": "latest" } 5 | ], 6 | "plugins": [ 7 | ["@semantic-release/commit-analyzer", { "preset": "conventionalcommits" }], 8 | ["@semantic-release/release-notes-generator", { "preset": "conventionalcommits" }], 9 | "@semantic-release/npm", 10 | "@semantic-release/github" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | licg9999@126.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.zh-Hans.md: -------------------------------------------------------------------------------- 1 | # 贡献者公约 2 | 3 | ## 我们的承诺 4 | 5 | 身为社区成员、贡献者和领袖,我们承诺使社区参与者不受骚扰,无论其年龄、体型、可见或不可见的缺陷、族裔、性征、性别认同和表达、经验水平、教育程度、社会与经济地位、国籍、相貌、种族、种姓、肤色、宗教信仰、性倾向或性取向如何。 6 | 7 | 我们承诺以有助于建立开放、友善、多样化、包容、健康社区的方式行事和互动。 8 | 9 | ## 我们的准则 10 | 11 | 有助于为我们的社区创造积极环境的行为例子包括但不限于: 12 | 13 | - 表现出对他人的同情和善意 14 | - 尊重不同的主张、观点和感受 15 | - 提出和大方接受建设性意见 16 | - 承担责任并向受我们错误影响的人道歉 17 | - 注重社区共同诉求,而非个人得失 18 | 19 | 不当行为例子包括: 20 | 21 | - 使用情色化的语言或图像,及性引诱或挑逗 22 | - 嘲弄、侮辱或诋毁性评论,以及人身或政治攻击 23 | - 公开或私下的骚扰行为 24 | - 未经他人明确许可,公布他人的私人信息,如物理或电子邮件地址 25 | - 其他有理由认定为违反职业操守的不当行为 26 | 27 | ## 责任和权力 28 | 29 | 社区领袖有责任解释和落实我们所认可的行为准则,并妥善公正地对他们认为不当、威胁、冒犯或有害的任何行为采取纠正措施。 30 | 31 | 社区领导有权力和责任删除、编辑或拒绝或拒绝与本行为准则不相符的评论(comment)、提交(commits)、代码、维基(wiki)编辑、议题(issues)或其他贡献,并在适当时机知采取措施的理由。 32 | 33 | ## 适用范围 34 | 35 | 本行为准则适用于所有社区场合,也适用于在公共场所代表社区时的个人。 36 | 37 | 代表社区的情形包括使用官方电子邮件地址、通过官方社交媒体帐户发帖或在线上或线下活动中担任指定代表。 38 | 39 | ## 监督 40 | 41 | 辱骂、骚扰或其他不可接受的行为可通过 licg9999@126.com 向负责监督的社区领袖报告。 42 | 所有投诉都将得到及时和公平的审查和调查。 43 | 44 | 所有社区领袖都有义务尊重任何事件报告者的隐私和安全。 45 | 46 | ## 处理方针 47 | 48 | 社区领袖将遵循下列社区处理方针来明确他们所认定违反本行为准则的行为的处理方式: 49 | 50 | ### 1. 纠正 51 | 52 | **社区影响**:使用不恰当的语言或其他在社区中被认定为不符合职业道德或不受欢迎的行为。 53 | 54 | **处理意见**:由社区领袖发出非公开的书面警告,明确说明违规行为的性质,并解释举止如何不妥。或将要求公开道歉。 55 | 56 | ### 2. 警告 57 | 58 | **社区影响**:单个或一系列违规行为。 59 | 60 | **处理意见**:警告并对连续性行为进行处理。在指定时间内,不得与相关人员互动,包括主动与行为准则执行者互动。这包括避免在社区场所和外部渠道中的互动。违反这些条款可能会导致临时或永久封禁。 61 | 62 | ### 3. 临时封禁 63 | 64 | **社区影响**: 严重违反社区准则,包括持续的不当行为。 65 | 66 | **处理意见**: 在指定时间内,暂时禁止与社区进行任何形式的互动或公开交流。在此期间,不得与相关人员进行公开或私下互动,包括主动与行为准则执行者互动。违反这些条款可能会导致永久封禁。 67 | 68 | ### 4. 永久封禁 69 | 70 | **社区影响**:行为模式表现出违反社区准则,包括持续的不当行为、骚扰个人或攻击或贬低某个类别的个体。 71 | 72 | **处理意见**:永久禁止在社区内进行任何形式的公开互动。 73 | 74 | ## 参见 75 | 76 | 本行为准则改编自 [Contributor Covenant][homepage] 2.1 版, 参见 [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]。 77 | 78 | 社区处理方针灵感来源于 [Mozilla's code of conduct enforcement ladder][Mozilla CoC]。 79 | 80 | 有关本行为准则的常见问题的答案,参见 [https://www.contributor-covenant.org/faq][FAQ]。 81 | 其他语言翻译参见 [https://www.contributor-covenant.org/translations][translations]。 82 | 83 | [homepage]: https://www.contributor-covenant.org 84 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 85 | [Mozilla CoC]: https://github.com/mozilla/diversity 86 | [FAQ]: https://www.contributor-covenant.org/faq 87 | [translations]: https://www.contributor-covenant.org/translations 88 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Chungen Li 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Statofu React 3 |

4 | 5 | [![Coverage](https://img.shields.io/codecov/c/github/statofu/statofu-react/latest)](https://codecov.io/gh/statofu/statofu-react) 6 | [![Verify and release](https://img.shields.io/github/actions/workflow/status/statofu/statofu-react/verify-and-release.yml?branch=latest&label=verify%20and%20release)](https://github.com/statofu/statofu-react/actions/workflows/verify-and-release.yml) 7 | [![Npm Version](https://img.shields.io/npm/v/statofu-react)](https://npmjs.com/package/statofu-react) 8 | [![Minzipped size](https://img.shields.io/bundlephobia/minzip/statofu-react)](https://bundlephobia.com/package/statofu-react) 9 | [![License](https://img.shields.io/github/license/statofu/statofu-react)](./LICENSE) 10 | 11 | English | [中文](./README.zh-Hans.md) 12 | 13 | ## Why Statofu React? 14 | 15 | One big problem with today's widely accepted state management libraries is that predictable state changes have to come at a high cost. [A detailed article](https://github.com/statofu/statofu-blog/blob/main/20230525/README.en.md) was written for the explanation: 16 | 17 | > ... 18 | > 19 | > Though, Redux is not perfect and has a drawback. If we take a closer look at its unidirectional data flow, `event -> action -> reducer -> state`, it's lengthy. No matter how simple a state change is, always at least one action and at least one reducer are involved. In comparison, a state change in either Recoil or MobX goes much easier. The lengthiness dramatically increases the cost of use in Redux. 20 | > 21 | > ... 22 | 23 | Statofu is a state management library built to achieve **predictable state changes at a low cost** 🌈. It's framework-agnostic, small and fast. Statofu React is the library of the React integration. 24 | 25 | ## Installation 26 | 27 | ```sh 28 | npm i -S statofu statofu-react # yarn or pnpm also works 29 | ``` 30 | 31 | The state management library, [`statofu`](https://github.com/statofu/statofu), is required as the peer dependency of the React integration, `statofu-react`. 32 | 33 | ## Essentials 34 | 35 | In Statofu, each kind of state change directly involves a different reducer that accepts one or multiple old states along with zero or more payloads and produces one or multiple new corresponding states. As reducers are pure functions, state changes are predictable. As reducers are directly involved, state changes come at a low cost. The usage is described as follows. (Or, [an runnable example is available here](./examples/select-item-to-edit).) 36 | 37 | ### Setting up the store 38 | 39 | First of all, a Statofu store needs to be set up for child components, for which `StoreProvider` is used. It can wrap either the whole app or only some components: 40 | 41 | ```tsx 42 | import { StoreProvider } from 'statofu-react'; 43 | 44 | // ..., either: 45 | 46 | root.render( 47 | 48 | 49 | 50 | ); 51 | 52 | // ..., or: 53 | 54 | const SomeComponentsWithStore: React.FC = () => { 55 | return ( 56 | 57 | 58 | 59 | ); 60 | }; 61 | ``` 62 | 63 | Besides, `withStore` can be used as an alternative to `StoreProvider`: 64 | 65 | ```tsx 66 | import { withStore } from 'statofu-react'; 67 | 68 | // ..., either: 69 | 70 | const AppWithStore = withStore()(App); 71 | 72 | root.render(); 73 | 74 | // ..., or: 75 | 76 | const SomeComponentsWithStore = withStore()(SomeComponents); 77 | ``` 78 | 79 | ### Defining states 80 | 81 | Next, states need to be defined, which is simply done by Plain Old JavaScript Object(POJO)s. A POJO, as a state definition, simultaneously, for a state, (1) holds the default state value, (2) declares the state type, and (3) indexes the current state value in a store. Here are two example state definitions, one for a selectable item panel, and the other for a hideable text editor: 82 | 83 | ```tsx 84 | interface ItemPanelState { 85 | itemList: { id: string; text: string }[]; 86 | selectedItemId: string | undefined; 87 | } 88 | 89 | const $itemPanelState: ItemPanelState = { 90 | itemList: [], 91 | selectedItemId: undefined, 92 | }; 93 | 94 | interface TextEditorState { 95 | text: string; 96 | visible: boolean; 97 | } 98 | 99 | const $textEditorState: TextEditorState = { 100 | text: '', 101 | visible: false, 102 | }; 103 | ``` 104 | 105 | Usually, to distinguish state definitions from state values by names, `$` is prefixed to state definition names. 106 | 107 | ### Getting states 108 | 109 | Then, to get state values in components, `useSnapshot` is used. It accepts one or multiple state definitions and returns the current one or multiple corresponding state values indexed by the state definitions: 110 | 111 | ```tsx 112 | import { useSnapshot } from 'statofu-react'; 113 | 114 | // ... 115 | 116 | const SomeComponent1: React.FC = () => { 117 | const { itemList, selectedItemId } = useSnapshot($itemPanelState); 118 | const { text, visible } = useSnapshot($textEditorState); 119 | 120 | // ... 121 | }; 122 | 123 | // ... 124 | 125 | const SomeComponent2: React.FC = () => { 126 | const [itemPanelState, textEditorState] = useSnapshot([$itemPanelState, $textEditorState]); 127 | 128 | // ... 129 | }; 130 | ``` 131 | 132 | By the way, before a state is changed, its state value is the shallow copy of the default state value held by its state definition. 133 | 134 | ### Changing states 135 | 136 | Now, let's dive into state changes. In Statofu, each kind of state change directly involves a different reducer. For changing one state, a reducer that accepts one old state along with zero or more payloads and produces one new corresponding state is involved. Here are three example reducers, two for changing `$itemPanelState`, and one for changing `$textEditorState`: 137 | 138 | ```tsx 139 | function selectItem(state: ItemPanelState, itemIdToSelect: string): ItemPanelState { 140 | return { ...state, selectedItemId: itemIdToSelect }; 141 | } 142 | 143 | function unselectItem(state: ItemPanelState): ItemPanelState { 144 | return { ...state, selectedItemId: undefined }; 145 | } 146 | 147 | function setText(state: TextEditorState, text: string): TextEditorState { 148 | return { ...state, text }; 149 | } 150 | ``` 151 | 152 | For changing multiple states, a reducer that accepts multiple old states along with zero or more payloads and produces multiple new corresponding states is involved. Here is an example reducer for changing `$itemPanelState` and `$textEditorState`: 153 | 154 | ```tsx 155 | function submitTextForSelectedItem([textEditor, itemPanel]: [TextEditorState, ItemPanelState]): [ 156 | TextEditorState, 157 | ItemPanelState 158 | ] { 159 | return [ 160 | { ...textEditor, visible: false }, 161 | { 162 | ...itemPanel, 163 | itemList: itemPanel.itemList.map((item) => { 164 | if (item.id === itemPanel.selectedItemId) { 165 | return { ...item, text: textEditor.text }; 166 | } else { 167 | return item; 168 | } 169 | }), 170 | selectedItemId: undefined, 171 | }, 172 | ]; 173 | } 174 | ``` 175 | 176 | With reducers ready, to involve them to change states in components, the operating function returned by `useOperate` is used: 177 | 178 | ```tsx 179 | import { useOperate } from 'statofu-react'; 180 | 181 | // ... 182 | 183 | const SomeComponent3: React.FC = () => { 184 | const op = useOperate(); 185 | 186 | function handleItemClick(itemId: string) { 187 | op($itemPanelState, selectItem, itemId); 188 | } 189 | 190 | function handleQuitClick() { 191 | op($itemPanelState, unselectItem); 192 | } 193 | 194 | function handleTextareaChange(e: React.ChangeEvent) { 195 | op($textEditorState, setText, e.target.value); 196 | } 197 | 198 | function handleSubmitClick() { 199 | op([$textEditorState, $itemPanelState], submitTextForSelectedItem); 200 | } 201 | 202 | return <>{/* attaches event handlers */}; 203 | }; 204 | ``` 205 | 206 | Inside a call of an operating function, the current state values indexed by the state definitions are passed into the reducer to produce the next state values which are, in turn, saved to the store. 207 | 208 | ### Deriving data 209 | 210 | Furthurmore, to derive data from states, a selector that accepts one or multiple states along with zero or more payloads and calculates a value can be passed in while using `useSnapshot`. Selectors can be named functions: 211 | 212 | ```tsx 213 | function getSelectedItem(state: ItemPanelState): ItemPanelState['itemList'][number] | undefined { 214 | return state.itemList.find(({ id }) => id === state.selectedItemId); 215 | } 216 | 217 | function getRelatedItems([itemPanel, textEditor]: [ 218 | ItemPanelState, 219 | TextEditorState 220 | ]): ItemPanelState['itemList'] { 221 | return itemPanel.itemList.filter(({ text }) => text.includes(textEditor.text)); 222 | } 223 | 224 | function getTextWithFallback(state: TextEditorState, fallback: string): string { 225 | return state.text || fallback; 226 | } 227 | 228 | function isVisible(state: TextEditorState): boolean { 229 | return state.visible; 230 | } 231 | 232 | const SomeComponent5: React.FC = () => { 233 | const selectedItem = useSnapshot($itemPanelState, getSelectedItem); 234 | const relatedItems = useSnapshot([$itemPanelState, $textEditorState], getRelatedItems); 235 | const textWithFallback = useSnapshot($textEditorState, getTextWithFallback, 'Not Available'); 236 | const visible = useSnapshot($textEditorState, isVisible); 237 | 238 | // ... 239 | }; 240 | ``` 241 | 242 | Also, selectors can be anonymous functions: 243 | 244 | ```tsx 245 | const SomeComponent6: React.FC = () => { 246 | const selectedItemId = useSnapshot($itemPanelState, (state) => state.selectedItemId); 247 | 248 | // ... 249 | }; 250 | ``` 251 | 252 | Note that, given the same inputs to a selector, the non-array outputs or the elements of the array outputs should remain referentially identical across separate calls so unnecessary rerenders are avoided. 253 | 254 | ## Recipes 255 | 256 | ### Code Structure 257 | 258 | In Statofu, the management of a state consists of (1) a state definition, (2) zero or more reducers, and (3) zero or more selectors. So, a recommended practice is to place the three parts of a state sequentially into one file, which leads to good maintainability. (In addition, as there are only POJOs and pure functions in each file, this code structure also leads to good portability.) Let's reorganize the states in Essentials for an example: 259 | 260 | ```tsx 261 | // states/ItemPanelState.ts 262 | import type { TextEditorState } from './TextEditorState'; 263 | 264 | export interface ItemPanelState { 265 | itemList: { id: string; text: string }[]; 266 | selectedItemId: string | undefined; 267 | } 268 | 269 | export const $itemPanelState: ItemPanelState = { 270 | itemList: [], 271 | selectedItemId: undefined, 272 | }; 273 | 274 | export function selectItem(state: ItemPanelState, itemIdToSelect: string): ItemPanelState { 275 | // ... 276 | } 277 | 278 | export function unselectItem(state: ItemPanelState): ItemPanelState { 279 | // ... 280 | } 281 | 282 | export function getSelectedItem( 283 | state: ItemPanelState 284 | ): ItemPanelState['itemList'][number] | undefined { 285 | // ... 286 | } 287 | 288 | export function getRelatedItems([itemPanel, textEditor]: [ 289 | ItemPanelState, 290 | TextEditorState 291 | ]): ItemPanelState['itemList'] { 292 | // ... 293 | } 294 | ``` 295 | 296 | ```tsx 297 | // states/TextEditorState.ts 298 | import type { ItemPanelState } from './ItemPanelState'; 299 | 300 | export interface TextEditorState { 301 | text: string; 302 | visible: boolean; 303 | } 304 | 305 | export const $textEditorState: TextEditorState = { 306 | text: '', 307 | visible: false, 308 | }; 309 | 310 | export function setText(state: TextEditorState, text: string): TextEditorState { 311 | // ... 312 | } 313 | 314 | export function submitTextForSelectedItem([textEditor, itemPanel]: [ 315 | TextEditorState, 316 | ItemPanelState 317 | ]): [TextEditorState, ItemPanelState] { 318 | // ... 319 | } 320 | 321 | export function getTextWithFallback(state: TextEditorState, fallback: string): string { 322 | // ... 323 | } 324 | 325 | export function isVisible(state: TextEditorState): boolean { 326 | // ... 327 | } 328 | ``` 329 | 330 | ### Server-side rendering(SSR) 331 | 332 | In general, SSR needs 2 steps. (1) On the server side, states are prepared as per a page request, an HTML body is rendered with the states, and the states are serialized afterward. Then, the two are piped into the response. (2) On the client side, the server-serialized states are deserialized, then components are rendered with the states to properly hydrate the server-rendered HTML body. 333 | 334 | To help with SSR, Statofu provides helpers of bulk reading to-serialize states from a store and bulk writing deserialized states to a store. But, serialization/deserialization is beyond the scope because it's easily doable via a more specialized library such as `serialize-javascript` or some built-in features of a full-stack framework such as data fetching of `next`. 335 | 336 | Here is a semi-pseudocode example for SSR with Statofu. Firstly, `serialize-javascript` is installed for serialization/deserialization: 337 | 338 | ```sh 339 | npm i -S serialize-javascript 340 | ``` 341 | 342 | Next, on the server side: 343 | 344 | ```tsx 345 | import { renderToString } from 'react-dom/server'; 346 | import serialize from 'serialize-javascript'; 347 | import { createStatofuState } from 'statofu'; 348 | import { StoreProvider } from 'statofu-react'; 349 | import { foldStates } from 'statofu/ssr'; 350 | 351 | // ... 352 | 353 | app.get('/some-page', (req, res) => { 354 | const store = createStatofuState(); 355 | 356 | const itemPanelState = prepareItemPanelState(req); 357 | store.operate($itemPanelState, itemPanelState); 358 | 359 | const textEditorState = prepareItemPanelState(req); 360 | store.operate($textEditorState, textEditorState); 361 | 362 | const htmlBody = renderToString( 363 | 364 | 365 | 366 | ); 367 | 368 | const stateFolder = foldStates(store, { $itemPanelState, $textEditorState }); 369 | 370 | res.send(` 371 | ... 372 | 373 | ... 374 |
${htmlBody}
375 | ...`); 376 | }); 377 | ``` 378 | 379 | Afterward, on the client side: 380 | 381 | ```tsx 382 | import { hydrateRoot } from 'react-dom/client'; 383 | import { StoreProvider } from 'statofu-react'; 384 | import { unfoldStates } from 'statofu/ssr'; 385 | 386 | // ... 387 | 388 | const stateFolder = eval(`(${window.SERIALIZED_STATE_FOLDER})`); 389 | 390 | delete window.SERIALIZED_STATE_FOLDER; 391 | 392 | hydrateRoot( 393 | elRoot, 394 | { 396 | unfoldStates(store, { $itemPanelState, $textEditorState }, stateFolder); 397 | }} 398 | > 399 | 400 | 401 | ); 402 | ``` 403 | 404 | Note that, this example can be optimized in different ways like rendering the HTML body as a stream. When using it in the real world, we should tailor it to real-world needs. 405 | 406 | ## APIs 407 | 408 | ### `StoreProvider` 409 | 410 | The component to set up a Statofu store for child components: 411 | 412 | ```tsx 413 | 414 | 415 | 416 | ``` 417 | 418 | Options: 419 | 420 | - `store?: StatofuStore`: The store provided outside. 421 | - `onCreate?: (store: StatofuStore) => void`: The callback invoked on a store created inside. If `store` is present, the callback is not called. 422 | 423 | ### `withStore` 424 | 425 | The higher-order component(HOC) version of `StoreProvider`: 426 | 427 | ```tsx 428 | const AppWithStore = withStore(/* options */)(App); 429 | ``` 430 | 431 | Options: same as `StoreProvider`'s. 432 | 433 | ### `useStore` 434 | 435 | The hook to get the store: 436 | 437 | ```tsx 438 | const store = useStore(); 439 | ``` 440 | 441 | ### `useSnapshot` 442 | 443 | The hook to get state values: 444 | 445 | ```tsx 446 | const { itemList, selectedItemId } = useSnapshot($itemPanelState); 447 | const { text, visible } = useSnapshot($textEditorState); 448 | const [itemPanelState, textEditorState] = useSnapshot([$itemPanelState, $textEditorState]); 449 | ``` 450 | 451 | It can accept selectors: 452 | 453 | ```tsx 454 | const selectedItem = useSnapshot($itemPanelState, getSelectedItem); 455 | const relatedItems = useSnapshot([$itemPanelState, $textEditorState], getRelatedItems); 456 | const textWithFallback = useSnapshot($textEditorState, getTextWithFallback, 'Not Available'); 457 | const visible = useSnapshot($textEditorState, isVisible); 458 | const selectedItemId = useSnapshot($itemPanelState, (state) => state.selectedItemId); 459 | ``` 460 | 461 | ### `useOperate` 462 | 463 | The hook to return the operating function for changing states by involving reducers: 464 | 465 | ```tsx 466 | const op = useOperate(); 467 | 468 | function handleItemClick(itemId: string) { 469 | op($itemPanelState, selectItem, itemId); 470 | } 471 | 472 | function handleQuitClick() { 473 | op($itemPanelState, unselectItem); 474 | } 475 | 476 | function handleTextareaChange(e: React.ChangeEvent) { 477 | op($textEditorState, setText, e.target.value); 478 | } 479 | 480 | function handleSubmitClick() { 481 | op([$textEditorState, $itemPanelState], submitTextForSelectedItem); 482 | } 483 | ``` 484 | 485 | ## Contributing 486 | 487 | For any bugs or any thoughts, welcome to [open an issue](https://github.com/statofu/statofu-react/issues), or just DM me on [Twitter](https://twitter.com/licg9999) / [Wechat](https://github.com/statofu/statofu/blob/main/assets/ed0458952a4930f1aeebd01da0127de240c85bbf.jpg). 488 | 489 | ## License 490 | 491 | MIT, details in the [LICENSE](./LICENSE) file. 492 | -------------------------------------------------------------------------------- /README.zh-Hans.md: -------------------------------------------------------------------------------- 1 |

2 | Statofu React 3 |

4 | 5 | [![Coverage](https://img.shields.io/codecov/c/github/statofu/statofu-react/latest)](https://codecov.io/gh/statofu/statofu-react) 6 | [![Verify and release](https://img.shields.io/github/actions/workflow/status/statofu/statofu-react/verify-and-release.yml?branch=latest&label=verify%20and%20release)](https://github.com/statofu/statofu-react/actions/workflows/verify-and-release.yml) 7 | [![Npm Version](https://img.shields.io/npm/v/statofu-react)](https://npmjs.com/package/statofu-react) 8 | [![Minzipped size](https://img.shields.io/bundlephobia/minzip/statofu-react)](https://bundlephobia.com/package/statofu-react) 9 | [![License](https://img.shields.io/github/license/statofu/statofu-react)](./LICENSE) 10 | 11 | [English](./README.md) | 中文 12 | 13 | ## TODO 14 | 15 | 正在全力撰写中,家人们稍安勿躁~ 16 | -------------------------------------------------------------------------------- /additional-configs/.jest-puppeteerrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "launch": { 3 | "headless": "new" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /additional-configs/jest.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | declare module 'ts-jest/presets/js-with-ts/jest-preset' { 6 | const preset: object; 7 | export = preset; 8 | } 9 | 10 | declare module 'jest-puppeteer/jest-preset' { 11 | const preset: object; 12 | export = preset; 13 | } 14 | 15 | var e2eGlobal: 16 | | { 17 | servePid?: number; 18 | verdaccioPid?: number; 19 | } 20 | | undefined; 21 | -------------------------------------------------------------------------------- /additional-configs/jestGlobalSetupE2E.ts: -------------------------------------------------------------------------------- 1 | import crossSpawn from 'cross-spawn'; 2 | import puppeteerPreset from 'jest-puppeteer'; 3 | import killPort from 'kill-port'; 4 | 5 | import { 6 | PKG_DIR, 7 | PKG_NAME, 8 | PKG_TAG_E2E, 9 | SERVE_PORT, 10 | stdoutLog, 11 | throwErrIfNpmErr, 12 | VERDACCIO_E, 13 | VERDACCIO_ORIGIN, 14 | VERDACCIO_P, 15 | VERDACCIO_PORT, 16 | VERDACCIO_U, 17 | waitForTextInStream, 18 | } from '../e2e/helpers'; 19 | 20 | export default async (globalConfig: unknown) => { 21 | if (process.cwd() !== PKG_DIR) { 22 | stdoutLog('Changed dir back to package dir'); 23 | process.chdir(PKG_DIR); 24 | } 25 | 26 | globalThis.e2eGlobal = {}; 27 | 28 | try { 29 | stdoutLog(`Trying to kill 'serve' by port '${SERVE_PORT}'...`); 30 | await killPort(SERVE_PORT); 31 | stdoutLog(`Killed 'serve' by port '${SERVE_PORT}'`); 32 | } catch { 33 | stdoutLog(`Didn't find 'serve' by port '${SERVE_PORT}'`); 34 | } 35 | stdoutLog(`Launching 'serve' on port '${SERVE_PORT}'...`); 36 | crossSpawn.sync('npm', ['run', 'serve', '--', '--help']); 37 | const serveProc = crossSpawn('npm', ['run', 'serve']); 38 | if (serveProc.stdout) { 39 | await waitForTextInStream(`:${SERVE_PORT}`, serveProc.stdout, 15000); 40 | } 41 | globalThis.e2eGlobal.servePid = serveProc.pid; 42 | stdoutLog(`Launched 'serve' on port '${SERVE_PORT}' at pid '${serveProc.pid}'`); 43 | 44 | try { 45 | stdoutLog(`Trying to kill 'verdaccio' by port '${VERDACCIO_PORT}'`); 46 | await killPort(VERDACCIO_PORT); 47 | stdoutLog(`Killed 'verdaccio' by port '${VERDACCIO_PORT}'`); 48 | } catch { 49 | stdoutLog(`Didn't find 'verdaccio' by port '${VERDACCIO_PORT}'`); 50 | } 51 | stdoutLog(`Launching 'verdaccio' on port '${VERDACCIO_PORT}'...`); 52 | crossSpawn.sync('npm', ['run', 'verdaccio', '--', '--help']); 53 | const verdaccioProc = crossSpawn('npm', ['run', 'verdaccio']); 54 | if (verdaccioProc.stdout) { 55 | await waitForTextInStream(`:${VERDACCIO_PORT}`, verdaccioProc.stdout, 20000); 56 | } 57 | globalThis.e2eGlobal.verdaccioPid = verdaccioProc.pid; 58 | stdoutLog(`Launched 'verdaccio' on port '${VERDACCIO_PORT}' at pid '${verdaccioProc.pid}'`); 59 | 60 | stdoutLog(`Preparing package '${PKG_NAME}' in 'verdaccio'...`); 61 | 62 | throwErrIfNpmErr( 63 | crossSpawn.sync('npx', [ 64 | '-y', 65 | 'npm-cli-login@1', 66 | '-u', 67 | VERDACCIO_U, 68 | '-p', 69 | VERDACCIO_P, 70 | '-e', 71 | VERDACCIO_E, 72 | '-r', 73 | VERDACCIO_ORIGIN, 74 | ]), 75 | 'Failed to login npm' 76 | ); 77 | 78 | throwErrIfNpmErr( 79 | crossSpawn.sync('npm', ['unpublish', PKG_NAME, '-f', '--registry', VERDACCIO_ORIGIN]), 80 | `Failed to unpublish '${PKG_NAME}'` 81 | ); 82 | 83 | throwErrIfNpmErr( 84 | crossSpawn.sync('npm', ['publish', '--registry', VERDACCIO_ORIGIN, '--tag', PKG_TAG_E2E]), 85 | `Failed to publish '${PKG_NAME}'` 86 | ); 87 | 88 | stdoutLog(`Prepared package '${PKG_NAME}' in 'verdaccio'`); 89 | 90 | await require(puppeteerPreset.globalSetup)(globalConfig); 91 | }; 92 | -------------------------------------------------------------------------------- /additional-configs/jestGlobalTeardownE2E.ts: -------------------------------------------------------------------------------- 1 | import { promisify } from 'node:util'; 2 | 3 | import puppeteerPreset from 'jest-puppeteer'; 4 | import treeKill from 'tree-kill'; 5 | 6 | import { PKG_DIR, stdoutLog } from '../e2e/helpers'; 7 | 8 | module.exports = async (globalConfig: unknown) => { 9 | if (process.cwd() !== PKG_DIR) { 10 | stdoutLog('Changed dir back to package dir'); 11 | process.chdir(PKG_DIR); 12 | } 13 | 14 | const { servePid, verdaccioPid } = globalThis.e2eGlobal ?? {}; 15 | 16 | if (typeof servePid === 'number') { 17 | await promisify(treeKill)(servePid); 18 | stdoutLog(`Killed 'serve' by pid '${servePid}'`); 19 | } 20 | 21 | if (typeof verdaccioPid === 'number') { 22 | await promisify(treeKill)(verdaccioPid); 23 | stdoutLog(`Killed 'verdaccio' by pid '${verdaccioPid}'`); 24 | } 25 | 26 | await require(puppeteerPreset.globalTeardown)(globalConfig); 27 | }; 28 | -------------------------------------------------------------------------------- /additional-configs/jestSetupAfterEnvUnit.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | -------------------------------------------------------------------------------- /additional-configs/tsconfig.types.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "declarationMap": true, 6 | "emitDeclarationOnly": true, 7 | "noEmit": false, 8 | "outDir": "../dist", 9 | "rootDir": "../src" 10 | }, 11 | "include": ["../src"], 12 | "exclude": ["../**/__tests__", "../**/*.spec.*", "../**/*.test.*", "../**/*.test-d.*"] 13 | } 14 | -------------------------------------------------------------------------------- /assets/659f196c95dfb272d6648a89c5aedaf3e9a94a0d.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statofu/statofu-react/0c38dd2f877503437f37a7c7eb2350ea01e3bf13/assets/659f196c95dfb272d6648a89c5aedaf3e9a94a0d.gif -------------------------------------------------------------------------------- /assets/fa668bd880860a5790b4a5bc0d1d0f40adebd47d.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/statofu/statofu-react/0c38dd2f877503437f37a7c7eb2350ea01e3bf13/assets/fa668bd880860a5790b4a5bc0d1d0f40adebd47d.jpg -------------------------------------------------------------------------------- /e2e/basic-use-in-different-formats.spec.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import os from 'node:os'; 3 | 4 | import crossSpawn from 'cross-spawn'; 5 | import { globSync } from 'glob'; 6 | 7 | import { PKG_NAME, SERVE_ORIGIN, throwErrIfNpmErr, VERDACCIO_ORIGIN } from './helpers'; 8 | 9 | const PREV_DIR = process.cwd(); 10 | 11 | const BASE_DIR = 'e2e/fixtures/basic-use-in-different-formats'; 12 | 13 | const BASE_URL = `${SERVE_ORIGIN}/${BASE_DIR}`; 14 | 15 | beforeAll(() => { 16 | process.chdir(BASE_DIR); 17 | throwErrIfNpmErr( 18 | crossSpawn.sync('npm', ['uninstall', '--no-save', PKG_NAME]), 19 | `Failed to uninstall '${PKG_NAME}'` 20 | ); 21 | ['dist', 'package-lock.json'].forEach((p) => { 22 | try { 23 | fs.rmSync(p, { recursive: true }); 24 | } catch {} 25 | }); 26 | throwErrIfNpmErr( 27 | crossSpawn.sync('npm', ['i', '--registry', VERDACCIO_ORIGIN]), 28 | 'Failed to install deps' 29 | ); 30 | appendLogsToStatofuReactInNodeModules(); 31 | throwErrIfNpmErr(crossSpawn.sync('npx', ['webpack']), 'Webpack failure'); 32 | 33 | ['obsolete-module-resolution'].forEach((subdir) => { 34 | try { 35 | fs.rmSync(`${subdir}/dist`, { recursive: true }); 36 | } catch {} 37 | throwErrIfNpmErr( 38 | crossSpawn.sync('node', [`${subdir}/scripts/prepare.js`]), 39 | `Failed to prepare dir '${subdir}'` 40 | ); 41 | throwErrIfNpmErr( 42 | crossSpawn.sync('npx', [ 43 | 'webpack', 44 | '--config', 45 | `./${subdir}/webpack.config.js`, 46 | '--config', 47 | `./${subdir}/webpack.merge.config.js`, 48 | ]), 49 | `Webpack failure (in subdir '${subdir}')` 50 | ); 51 | }); 52 | }); 53 | 54 | afterAll(() => { 55 | process.chdir(PREV_DIR); 56 | }); 57 | 58 | describe('on browser', () => { 59 | [ 60 | 'bundle-cjs', 61 | 'bundle-esm-by-default', 62 | 'bundle-umd', 63 | 'plain-script-umd', 64 | 'obsolete-module-resolution/bundle-esm-by-default', 65 | ].forEach((fullFormat) => { 66 | test(`in format '${fullFormat}', runs well with the correct files imported`, async () => { 67 | const { importedFormat, subdirEndingWithSlash, mainFileFormat } = parseFullFormat(fullFormat); 68 | const consoleLogs: string[] = []; 69 | page.on('console', (cm) => consoleLogs.push(cm.text())); 70 | await page.goto( 71 | `${BASE_URL}/${subdirEndingWithSlash}public/index.browser-${mainFileFormat}.html` 72 | ); 73 | const logsAsText = consoleLogs.join(os.EOL); 74 | expect(logsAsText).toInclude(`statofu-react/dist/statofu-react.${importedFormat}.min.js`); 75 | await expect(page).toMatchElement('#root', { text: 'success', timeout: 2000 }); 76 | }); 77 | }); 78 | }); 79 | 80 | describe('on node', () => { 81 | ['import-cjs-by-default', 'require-cjs-by-default'].forEach((fullFormat) => { 82 | test(`in format '${fullFormat}', runs well with the correct files imported`, () => { 83 | const { importedFormat, subdirEndingWithSlash, mainFileFormat } = parseFullFormat(fullFormat); 84 | const mainFilePrefix = `${subdirEndingWithSlash}src/index.node-${mainFileFormat}`; 85 | const mainFile = [`${mainFilePrefix}.js`, `${mainFilePrefix}.mjs`].find((p) => 86 | fs.existsSync(p) 87 | ); 88 | if (!mainFile) { 89 | throw new Error('Main file not found'); 90 | } 91 | const { stdout } = crossSpawn.sync('node', [mainFile], { encoding: 'utf8' }); 92 | expect(stdout).toInclude(`statofu-react/dist/statofu-react.${importedFormat}.min.js`); 93 | expect(stdout).toMatch('success'); 94 | }); 95 | }); 96 | }); 97 | 98 | describe('on test runner', () => { 99 | ['import-cjs-by-default', 'obsolete-module-resolution/import-cjs-by-default'].forEach( 100 | (fullFormat) => { 101 | test(`in format '${fullFormat}', runs well with the correct files imported`, () => { 102 | const { importedFormat, subdirEndingWithSlash, mainFileFormat } = 103 | parseFullFormat(fullFormat); 104 | const { stdout, stderr, error } = crossSpawn.sync( 105 | 'npx', 106 | [ 107 | 'jest', 108 | '--config', 109 | `./${subdirEndingWithSlash}jest.config.js`, 110 | `${subdirEndingWithSlash}src/index.test-${mainFileFormat}.ts`, 111 | ], 112 | { encoding: 'utf8' } 113 | ); 114 | expect(error).toBeFalsy(); 115 | const logsAsText = [stdout, stderr].join(os.EOL); 116 | expect(logsAsText).toInclude(`statofu-react/dist/statofu-react.${importedFormat}.min.js`); 117 | expect(logsAsText).toMatch('success'); 118 | }); 119 | } 120 | ); 121 | }); 122 | 123 | function appendLogsToStatofuReactInNodeModules() { 124 | globSync('node_modules/statofu-react/dist/*.js', { absolute: true }).forEach((file) => { 125 | const log = file.replace(/\\/g, '/'); 126 | fs.appendFileSync(file, `${os.EOL}console.log('${log}');${os.EOL}`, 'utf8'); 127 | }); 128 | } 129 | 130 | function parseFullFormat(fullFormat: string) { 131 | const importedFormat = fullFormat.match(/(bundle|script|import|require)-(\w+)-?/)?.[2]; 132 | const subdirEndingWithSlash = fullFormat.match(/[\w-]+\//)?.[0] ?? ''; 133 | const mainFileFormat = fullFormat.match(/[\w-]+$/)?.[0]; 134 | return { importedFormat, subdirEndingWithSlash, mainFileFormat }; 135 | } 136 | -------------------------------------------------------------------------------- /e2e/fixtures/basic-use-in-different-formats/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "globals": { 3 | "React": true, 4 | "ReactDOM": true, 5 | "statofuReact": true, 6 | "appLogics": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /e2e/fixtures/basic-use-in-different-formats/global.d.ts: -------------------------------------------------------------------------------- 1 | var React: typeof import('react'); 2 | var ReactDOM: typeof import('react-dom/client'); 3 | var statofuReact: typeof import('statofuReact'); 4 | 5 | var appLogics: { 6 | test(deps: { 7 | ReactDOMClient: typeof import('react-dom/client'); 8 | statofuReact: Pick< 9 | typeof import('statofu-react'), 10 | 'StoreProvider' | 'useOperate' | 'useSnapshot' 11 | >; 12 | elRoot: HTMLElement; 13 | }): Promise; 14 | 15 | withJsdomWindow( 16 | jsdomWin: import('jsdom').DOMWindow, 17 | callback: () => Promise 18 | ): Promise; 19 | }; 20 | -------------------------------------------------------------------------------- /e2e/fixtures/basic-use-in-different-formats/jest.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import("jest").Config} 3 | **/ 4 | const jestConf = { 5 | testEnvironment: 'jsdom', 6 | preset: 'ts-jest/presets/js-with-ts', 7 | testRegex: 'src/index\\.test-.*', 8 | }; 9 | 10 | module.exports = jestConf; 11 | -------------------------------------------------------------------------------- /e2e/fixtures/basic-use-in-different-formats/obsolete-module-resolution/.gitignore: -------------------------------------------------------------------------------- 1 | src/appLogics.tsx 2 | global.d.ts 3 | jest.config.js 4 | webpack.config.js 5 | -------------------------------------------------------------------------------- /e2e/fixtures/basic-use-in-different-formats/obsolete-module-resolution/public/index.browser-bundle-esm-by-default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /e2e/fixtures/basic-use-in-different-formats/obsolete-module-resolution/scripts/prepare.js: -------------------------------------------------------------------------------- 1 | const fs = require('node:fs'); 2 | 3 | const toCopy = { 4 | '../../src/appLogics.tsx': '../src/appLogics.tsx', 5 | '../../global.d.ts': '../global.d.ts', 6 | '../../jest.config.js': '../jest.config.js', 7 | '../../webpack.config.js': '../webpack.config.js', 8 | }; 9 | 10 | for (const [k, v] of Object.entries(toCopy)) { 11 | fs.copyFileSync(`${__dirname}/${k}`, `${__dirname}/${v}`); 12 | } 13 | -------------------------------------------------------------------------------- /e2e/fixtures/basic-use-in-different-formats/obsolete-module-resolution/src/index.browser-bundle-cjs.ts: -------------------------------------------------------------------------------- 1 | throw Error('File not implemented'); 2 | -------------------------------------------------------------------------------- /e2e/fixtures/basic-use-in-different-formats/obsolete-module-resolution/src/index.browser-bundle-esm-by-default.ts: -------------------------------------------------------------------------------- 1 | import ReactDOMClient from 'react-dom/client'; 2 | import { StoreProvider, useOperate, useSnapshot } from 'statofu-react'; 3 | 4 | import './appLogics'; 5 | 6 | (async () => { 7 | const elRoot = document.getElementById('root'); 8 | 9 | if (!elRoot) { 10 | throw new Error('Root element not found'); 11 | } 12 | 13 | await appLogics.test({ 14 | ReactDOMClient, 15 | statofuReact: { StoreProvider, useOperate, useSnapshot }, 16 | elRoot, 17 | }); 18 | })(); 19 | -------------------------------------------------------------------------------- /e2e/fixtures/basic-use-in-different-formats/obsolete-module-resolution/src/index.browser-bundle-umd.ts: -------------------------------------------------------------------------------- 1 | throw Error('File not implemented'); 2 | -------------------------------------------------------------------------------- /e2e/fixtures/basic-use-in-different-formats/obsolete-module-resolution/src/index.test-import-cjs-by-default.ts: -------------------------------------------------------------------------------- 1 | import ReactDOMClient from 'react-dom/client'; 2 | import { StoreProvider, useOperate, useSnapshot } from 'statofu-react'; 3 | 4 | import './appLogics'; 5 | 6 | test('runs app logics', async () => { 7 | document.body.innerHTML = '
'; 8 | 9 | const elRoot = document.getElementById('root'); 10 | 11 | if (!elRoot) { 12 | throw new Error('Root element not found'); 13 | } 14 | 15 | await expect( 16 | appLogics.test({ 17 | ReactDOMClient, 18 | statofuReact: { StoreProvider, useOperate, useSnapshot }, 19 | elRoot, 20 | }) 21 | ).resolves.not.toThrow(); 22 | 23 | console.log(document.documentElement.innerHTML); 24 | }); 25 | -------------------------------------------------------------------------------- /e2e/fixtures/basic-use-in-different-formats/obsolete-module-resolution/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "moduleResolution": "node", 5 | "rootDir": "." 6 | }, 7 | "include": ["."] 8 | } 9 | -------------------------------------------------------------------------------- /e2e/fixtures/basic-use-in-different-formats/obsolete-module-resolution/webpack.merge.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import("webpack").Configuration} 3 | */ 4 | const webpackConf = { 5 | context: __dirname, 6 | }; 7 | 8 | module.exports = webpackConf; 9 | -------------------------------------------------------------------------------- /e2e/fixtures/basic-use-in-different-formats/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "devDependencies": { 4 | "@babel/core": "^7.21.8", 5 | "@babel/preset-env": "^7.21.5", 6 | "@types/chai": "^4.3.5", 7 | "@types/jest": "^29.5.2", 8 | "@types/react": "^18.2.14", 9 | "@types/react-dom": "^18.2.6", 10 | "babel-loader": "^9.1.2", 11 | "chai": "^4.3.7", 12 | "jest": "^29.5.0", 13 | "jest-environment-jsdom": "^29.5.0", 14 | "jsdom": "^22.1.0", 15 | "react": "^18.2.0", 16 | "react-dom": "^18.2.0", 17 | "statofu": "^1", 18 | "statofu-react": "e2e-specific", 19 | "ts-jest": "^29.1.0", 20 | "ts-loader": "^9.4.2", 21 | "typescript": "^5.1.3", 22 | "webpack": "^5.88.0", 23 | "webpack-cli": "^5.1.4" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /e2e/fixtures/basic-use-in-different-formats/public/index.browser-bundle-cjs.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /e2e/fixtures/basic-use-in-different-formats/public/index.browser-bundle-esm-by-default.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /e2e/fixtures/basic-use-in-different-formats/public/index.browser-bundle-umd.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /e2e/fixtures/basic-use-in-different-formats/public/index.browser-plain-script-umd.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /e2e/fixtures/basic-use-in-different-formats/src/appLogics.tsx: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import React, { FC } from 'react'; 3 | 4 | globalThis.appLogics = { 5 | async test({ 6 | ReactDOMClient: { createRoot }, 7 | statofuReact: { StoreProvider, useOperate, useSnapshot }, 8 | elRoot, 9 | }) { 10 | interface A { 11 | a: string; 12 | } 13 | 14 | const $a: A = { a: 'a' }; 15 | 16 | function reduceA(a: A, p: string): A { 17 | return { ...a, a: p }; 18 | } 19 | 20 | const ResultText: FC = () => { 21 | const s = useSnapshot($a, (a) => a.a); 22 | return
{s}
; 23 | }; 24 | 25 | const Action: FC = () => { 26 | const operateReduceA = useOperate($a, reduceA); 27 | 28 | return ( 29 | 37 | ); 38 | }; 39 | 40 | const reactRoot = createRoot(elRoot); 41 | reactRoot.render( 42 | 43 | 44 | 45 | 46 | ); 47 | 48 | await tick(); 49 | 50 | expect(elRoot.querySelector('[role="result-text"]')?.innerHTML).to.equal('a'); 51 | 52 | elRoot.querySelector('[role="action-click"]')?.click(); 53 | 54 | await tick(); 55 | 56 | expect(elRoot.querySelector('[role="result-text"]')?.innerHTML).to.equal('a+'); 57 | 58 | reactRoot.unmount(); 59 | elRoot.innerHTML = 'success'; 60 | }, 61 | 62 | async withJsdomWindow(jsdomWin, callback) { 63 | const assignedKeys: string[] = []; 64 | 65 | for (const [k, v] of Object.entries(jsdomWin)) { 66 | if (k in globalThis) continue; 67 | assignedKeys.push(k); 68 | (globalThis as Record)[k] = v; 69 | } 70 | 71 | try { 72 | await callback(); 73 | } finally { 74 | for (const k of assignedKeys) { 75 | delete globalThis[k as never]; 76 | } 77 | } 78 | }, 79 | }; 80 | 81 | async function tick(ms: number = 500): Promise { 82 | return new Promise((resolve) => setTimeout(resolve, ms)); 83 | } 84 | -------------------------------------------------------------------------------- /e2e/fixtures/basic-use-in-different-formats/src/index.browser-bundle-cjs.ts: -------------------------------------------------------------------------------- 1 | import ReactDOMClient from 'react-dom/client'; 2 | import { StoreProvider, useOperate, useSnapshot } from 'statofu-react/cjs'; 3 | 4 | import './appLogics'; 5 | 6 | (async () => { 7 | const elRoot = document.getElementById('root'); 8 | 9 | if (!elRoot) { 10 | throw new Error('Root element not found'); 11 | } 12 | 13 | await appLogics.test({ 14 | ReactDOMClient, 15 | statofuReact: { StoreProvider, useOperate, useSnapshot }, 16 | elRoot, 17 | }); 18 | })(); 19 | -------------------------------------------------------------------------------- /e2e/fixtures/basic-use-in-different-formats/src/index.browser-bundle-esm-by-default.ts: -------------------------------------------------------------------------------- 1 | import ReactDOMClient from 'react-dom/client'; 2 | import { StoreProvider, useOperate, useSnapshot } from 'statofu-react'; 3 | 4 | import './appLogics'; 5 | 6 | (async () => { 7 | const elRoot = document.getElementById('root'); 8 | 9 | if (!elRoot) { 10 | throw new Error('Root element not found'); 11 | } 12 | 13 | await appLogics.test({ 14 | ReactDOMClient, 15 | statofuReact: { StoreProvider, useOperate, useSnapshot }, 16 | elRoot, 17 | }); 18 | })(); 19 | -------------------------------------------------------------------------------- /e2e/fixtures/basic-use-in-different-formats/src/index.browser-bundle-umd.ts: -------------------------------------------------------------------------------- 1 | import ReactDOMClient from 'react-dom/client'; 2 | import { StoreProvider, useOperate, useSnapshot } from 'statofu-react/umd'; 3 | 4 | import './appLogics'; 5 | 6 | (async () => { 7 | const elRoot = document.getElementById('root'); 8 | 9 | if (!elRoot) { 10 | throw new Error('Root element not found'); 11 | } 12 | 13 | await appLogics.test({ 14 | ReactDOMClient, 15 | statofuReact: { StoreProvider, useOperate, useSnapshot }, 16 | elRoot, 17 | }); 18 | })(); 19 | -------------------------------------------------------------------------------- /e2e/fixtures/basic-use-in-different-formats/src/index.browser-plain-script-umd.js: -------------------------------------------------------------------------------- 1 | const ReactDOMClient = ReactDOM; 2 | const { StoreProvider, useOperate, useSnapshot } = statofuReact; 3 | 4 | (async () => { 5 | const elRoot = document.getElementById('root'); 6 | 7 | if (!elRoot) { 8 | throw new Error('Root element not found'); 9 | } 10 | 11 | await appLogics.test({ 12 | ReactDOMClient, 13 | statofuReact: { StoreProvider, useOperate, useSnapshot }, 14 | elRoot, 15 | }); 16 | })(); 17 | -------------------------------------------------------------------------------- /e2e/fixtures/basic-use-in-different-formats/src/index.node-import-cjs-by-default.mjs: -------------------------------------------------------------------------------- 1 | import { JSDOM } from 'jsdom'; 2 | import ReactDOMClient from 'react-dom/client'; 3 | import { StoreProvider, useOperate, useSnapshot } from 'statofu-react'; 4 | 5 | (async () => { 6 | await import('../dist/appLogics.node.js'); 7 | 8 | const jsdom = new JSDOM( 9 | ` 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | `, 19 | { url: 'http://localhost:3030/' } 20 | ); 21 | 22 | const elRoot = jsdom.window.document.getElementById('root'); 23 | 24 | if (!elRoot) { 25 | throw new Error('Root element not found'); 26 | } 27 | 28 | await appLogics.withJsdomWindow(jsdom.window, () => 29 | appLogics.test({ 30 | ReactDOMClient, 31 | statofuReact: { StoreProvider, useOperate, useSnapshot }, 32 | elRoot, 33 | }) 34 | ); 35 | console.log(jsdom.serialize()); 36 | })(); 37 | -------------------------------------------------------------------------------- /e2e/fixtures/basic-use-in-different-formats/src/index.node-require-cjs-by-default.js: -------------------------------------------------------------------------------- 1 | const { JSDOM } = require('jsdom'); 2 | const ReactDOMClient = require('react-dom/client'); 3 | const { StoreProvider, useOperate, useSnapshot } = require('statofu-react'); 4 | 5 | require('../dist/appLogics.node'); 6 | 7 | (async () => { 8 | const jsdom = new JSDOM( 9 | ` 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | `, 19 | { url: 'http://localhost:3030/' } 20 | ); 21 | 22 | const elRoot = jsdom.window.document.getElementById('root'); 23 | 24 | if (!elRoot) { 25 | throw new Error('Root element not found'); 26 | } 27 | 28 | await appLogics.withJsdomWindow(jsdom.window, () => 29 | appLogics.test({ 30 | ReactDOMClient, 31 | statofuReact: { StoreProvider, useOperate, useSnapshot }, 32 | elRoot, 33 | }) 34 | ); 35 | console.log(jsdom.serialize()); 36 | })(); 37 | -------------------------------------------------------------------------------- /e2e/fixtures/basic-use-in-different-formats/src/index.test-import-cjs-by-default.ts: -------------------------------------------------------------------------------- 1 | import ReactDOMClient from 'react-dom/client'; 2 | import { StoreProvider, useOperate, useSnapshot } from 'statofu-react'; 3 | 4 | import './appLogics'; 5 | 6 | test('runs app logics', async () => { 7 | document.body.innerHTML = '
'; 8 | 9 | const elRoot = document.getElementById('root'); 10 | 11 | if (!elRoot) { 12 | throw new Error('Root element not found'); 13 | } 14 | 15 | await expect( 16 | appLogics.test({ 17 | ReactDOMClient, 18 | statofuReact: { StoreProvider, useOperate, useSnapshot }, 19 | elRoot, 20 | }) 21 | ).resolves.not.toThrow(); 22 | 23 | console.log(document.documentElement.innerHTML); 24 | }); 25 | -------------------------------------------------------------------------------- /e2e/fixtures/basic-use-in-different-formats/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../tsconfig.json", 3 | "compilerOptions": { 4 | "lib": ["esnext", "dom"], 5 | "moduleResolution": "nodenext", 6 | "noEmit": false, 7 | "rootDir": "." 8 | }, 9 | "include": ["."], 10 | "exclude": ["**/node_modules", "**/dist"] 11 | } 12 | -------------------------------------------------------------------------------- /e2e/fixtures/basic-use-in-different-formats/webpack.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {import("webpack").Configuration} WebpackConf 3 | */ 4 | 5 | /** 6 | * @type {WebpackConf} 7 | */ 8 | const commonConf = { 9 | mode: 'development', 10 | devtool: 'source-map', 11 | module: { 12 | rules: [ 13 | { 14 | test: /\.(tsx?|jsx)$/, 15 | use: [ 16 | 'babel-loader', 17 | { 18 | loader: 'ts-loader', 19 | options: { reportFiles: ['src/**/*.ts'] }, 20 | }, 21 | ], 22 | }, 23 | { 24 | test: /\.[cm]?js$/, 25 | use: 'babel-loader', 26 | }, 27 | ], 28 | }, 29 | resolve: { 30 | extensions: ['.ts', '.tsx', '.cjs', '.mjs', '...'], 31 | }, 32 | }; 33 | 34 | /** 35 | * @type {WebpackConf[]} 36 | */ 37 | const finalConfs = [ 38 | { 39 | ...commonConf, 40 | entry: './src/appLogics.tsx', 41 | output: { 42 | path: __dirname + '/dist', 43 | filename: 'appLogics.browser.js', 44 | }, 45 | externals: { 46 | react: 'React', 47 | }, 48 | }, 49 | { 50 | ...commonConf, 51 | entry: './src/appLogics.tsx', 52 | output: { 53 | path: __dirname + '/dist', 54 | filename: 'appLogics.node.js', 55 | }, 56 | externals: { 57 | react: 'commonjs react', 58 | }, 59 | }, 60 | { 61 | ...commonConf, 62 | entry: { 63 | 'index.browser-bundle-cjs': './src/index.browser-bundle-cjs.ts', 64 | 'index.browser-bundle-esm-by-default': './src/index.browser-bundle-esm-by-default.ts', 65 | 'index.browser-bundle-umd': './src/index.browser-bundle-umd.ts', 66 | }, 67 | output: { 68 | path: __dirname + '/dist', 69 | filename: '[name].js', 70 | }, 71 | }, 72 | ]; 73 | 74 | module.exports = finalConfs; 75 | -------------------------------------------------------------------------------- /e2e/helpers/constants.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | 3 | export const SERVE_PORT = 3030; 4 | export const SERVE_ORIGIN = `http://127.0.0.1:${SERVE_PORT}`; 5 | 6 | export const VERDACCIO_PORT = 7373; 7 | export const VERDACCIO_ORIGIN = `http://127.0.0.1:${VERDACCIO_PORT}`; 8 | export const VERDACCIO_U = 'statofu-e2e'; 9 | export const VERDACCIO_P = 'statofu1234'; 10 | export const VERDACCIO_E = 'statofu-e2e@statofu.local'; 11 | 12 | export const PKG_TAG_E2E = 'e2e-specific'; 13 | export const PKG_NAME = 'statofu-react'; 14 | export const PKG_DIR = path.resolve(__dirname + '/../..'); 15 | -------------------------------------------------------------------------------- /e2e/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './constants'; 2 | export * from './logger'; 3 | export * from './throwErr'; 4 | export * from './waitFor'; 5 | -------------------------------------------------------------------------------- /e2e/helpers/logger.ts: -------------------------------------------------------------------------------- 1 | import os from 'node:os'; 2 | import { inspect } from 'node:util'; 3 | 4 | export function stdoutLog(...texts: unknown[]) { 5 | const { stdout } = process; 6 | stdout.write(os.EOL); 7 | stdout.write( 8 | texts 9 | .map((t) => { 10 | return typeof t === 'string' 11 | ? t 12 | : inspect(t, { 13 | colors: stdout.hasColors(), 14 | }); 15 | }) 16 | .join(' ') 17 | ); 18 | stdout.write(os.EOL); 19 | } 20 | -------------------------------------------------------------------------------- /e2e/helpers/throwErr.ts: -------------------------------------------------------------------------------- 1 | import { SpawnSyncReturns } from 'node:child_process'; 2 | import os from 'node:os'; 3 | 4 | export function throwErrIfNpmErr( 5 | spawnSyncReturns: SpawnSyncReturns | SpawnSyncReturns, 6 | errorMessage: string 7 | ): void { 8 | const errStr = spawnSyncReturns.stderr.toString(); 9 | if (errStr.includes('ERR!')) { 10 | throw new Error(`${errorMessage}:${os.EOL} ${errStr}`); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /e2e/helpers/waitFor.ts: -------------------------------------------------------------------------------- 1 | import os from 'node:os'; 2 | import { Readable } from 'node:stream'; 3 | 4 | export async function waitForTextInStream( 5 | text: string, 6 | stream: Readable, 7 | timeout: number = 5000 8 | ): Promise { 9 | return await new Promise((resolve, reject) => { 10 | let output: string = ''; 11 | 12 | let done = false; 13 | 14 | stream.on('readable', () => { 15 | if (done) return; 16 | 17 | const chunk: Buffer | string | null = stream.read(); 18 | 19 | if (chunk === null) { 20 | done = true; 21 | reject(new Error(`Text '${text}' not found${os.EOL} ${output}`)); 22 | } else { 23 | output += chunk.toString(); 24 | if (output.includes(text)) { 25 | done = true; 26 | resolve(output); 27 | } 28 | } 29 | }); 30 | 31 | setTimeout(() => { 32 | if (done) return; 33 | done = true; 34 | reject(new Error(`Time '${timeout}ms' is out:${os.EOL} ${output}`)); 35 | }, timeout); 36 | }); 37 | } 38 | 39 | export async function waitForMs(ms: number): Promise { 40 | await new Promise((resolve) => setTimeout(resolve, ms)); 41 | } 42 | -------------------------------------------------------------------------------- /examples/select-item-to-edit/README.md: -------------------------------------------------------------------------------- 1 | ![Screen Record](../../assets/659f196c95dfb272d6648a89c5aedaf3e9a94a0d.gif) 2 | -------------------------------------------------------------------------------- /examples/select-item-to-edit/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@examples/select-item-to-edit", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.16.5", 7 | "@testing-library/react": "^13.4.0", 8 | "@testing-library/user-event": "^13.5.0", 9 | "@types/jest": "^27.5.2", 10 | "@types/node": "^16.18.38", 11 | "@types/react": "^18.2.14", 12 | "@types/react-dom": "^18.2.6", 13 | "bootstrap": "^5.3.0", 14 | "nanoid": "^4.0.2", 15 | "react": "^18.2.0", 16 | "react-dom": "^18.2.0", 17 | "react-scripts": "5.0.1", 18 | "statofu": "^1", 19 | "statofu-react": "^1", 20 | "typescript": "^4.9.5", 21 | "web-vitals": "^2.1.4" 22 | }, 23 | "scripts": { 24 | "start": "react-scripts start", 25 | "build": "react-scripts build", 26 | "test": "react-scripts test", 27 | "eject": "react-scripts eject" 28 | }, 29 | "eslintConfig": { 30 | "extends": [ 31 | "react-app", 32 | "react-app/jest" 33 | ] 34 | }, 35 | "browserslist": { 36 | "production": [ 37 | ">0.2%", 38 | "not dead", 39 | "not op_mini all" 40 | ], 41 | "development": [ 42 | "last 1 chrome version", 43 | "last 1 firefox version", 44 | "last 1 safari version" 45 | ] 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /examples/select-item-to-edit/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 17 | React App 18 | 19 | 20 | 21 |
22 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /examples/select-item-to-edit/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react'; 2 | 3 | import { ItemList } from './components/ItemList'; 4 | import { ItemListHeader } from './components/ItemListHeader'; 5 | import { TextEditorDialog } from './components/TextEditorDialog'; 6 | 7 | export const App: FC = () => { 8 | return ( 9 |
10 | 11 | 12 | 13 |
14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /examples/select-item-to-edit/src/components/ItemList.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react'; 2 | import { useOperate, useSnapshot } from 'statofu-react'; 3 | 4 | import { $itemPanelState, selectItem } from '../states/ItemPanelState'; 5 | 6 | export const ItemList: FC = () => { 7 | const { itemList, selectedItemId } = useSnapshot($itemPanelState); 8 | 9 | const op = useOperate(); 10 | 11 | function handleItemClick(itemId: string) { 12 | op($itemPanelState, selectItem, itemId); 13 | } 14 | 15 | return ( 16 |
    17 | {itemList.map(({ id, text }) => { 18 | const isSelected = id === selectedItemId; 19 | return ( 20 |
  1. handleItemClick(id)} 24 | > 25 | {text} 26 |
  2. 27 | ); 28 | })} 29 |
30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /examples/select-item-to-edit/src/components/ItemListHeader.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react'; 2 | import { useOperate, useSnapshot } from 'statofu-react'; 3 | 4 | import { $itemPanelState, getSelectedItem, unselectItem } from '../states/ItemPanelState'; 5 | import { $textEditorState, showWithTextCleared, showWithTextSet } from '../states/TextEditorState'; 6 | 7 | export const ItemListHeader: FC = () => { 8 | const selectedItem = useSnapshot($itemPanelState, getSelectedItem); 9 | 10 | const op = useOperate(); 11 | 12 | function handleNewItemClick() { 13 | op($textEditorState, showWithTextCleared); 14 | } 15 | 16 | function handleEditClick() { 17 | if (selectedItem) { 18 | op($textEditorState, showWithTextSet, selectedItem.text); 19 | } 20 | } 21 | 22 | function handleQuitClick() { 23 | op($itemPanelState, unselectItem); 24 | } 25 | 26 | return ( 27 |
28 | {selectedItem ? ( 29 | <> 30 | 31 | 32 | 33 | ) : ( 34 | 35 | )} 36 |
37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /examples/select-item-to-edit/src/components/TextEditorDialog.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent, FC, useEffect } from 'react'; 2 | import { useOperate, useSnapshot } from 'statofu-react'; 3 | 4 | import { $itemPanelState } from '../states/ItemPanelState'; 5 | import { 6 | $textEditorState, 7 | hideWithTextCleared, 8 | setText, 9 | submitTextForNewItem, 10 | submitTextForSelectedItem, 11 | } from '../states/TextEditorState'; 12 | 13 | export const TextEditorDialog: FC = () => { 14 | const { text, visible } = useSnapshot($textEditorState); 15 | const selectedItemId = useSnapshot($itemPanelState, (state) => state.selectedItemId); 16 | 17 | const op = useOperate(); 18 | 19 | function handleTextareaChange(e: ChangeEvent) { 20 | op($textEditorState, setText, e.target.value); 21 | } 22 | 23 | function handleSubmitClick() { 24 | op( 25 | [$textEditorState, $itemPanelState], 26 | selectedItemId ? submitTextForSelectedItem : submitTextForNewItem 27 | ); 28 | } 29 | 30 | function handleCancelClick() { 31 | op($textEditorState, hideWithTextCleared); 32 | } 33 | 34 | useEffect(() => { 35 | op($textEditorState, hideWithTextCleared); 36 | }, [op, selectedItemId]); 37 | 38 | return ( 39 | <> 40 | {visible && ( 41 |
42 |
43 |