├── .all-contributorsrc ├── .github └── workflows │ └── CI.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── assets ├── logo-128.png ├── logo-256.png ├── logo-48.png └── logo-64.png ├── package-lock.json ├── package.json ├── packages ├── core │ ├── README.md │ ├── babel.config.js │ ├── cjsBuild.js │ ├── dist │ │ ├── index.cjs │ │ └── index.js │ ├── package.json │ ├── src │ │ ├── Subscribe.test.tsx │ │ ├── Subscribe.tsx │ │ ├── bind │ │ │ ├── connectFactoryObservable.test.tsx │ │ │ ├── connectFactoryObservable.ts │ │ │ ├── connectObservable.test.tsx │ │ │ ├── connectObservable.ts │ │ │ └── index.ts │ │ ├── index.tsx │ │ ├── internal │ │ │ ├── empty-value.ts │ │ │ ├── useSyncExternalStore.ts │ │ │ └── useSyncExternalStoreCjs.ts │ │ ├── shareLatest.test.ts │ │ ├── shareLatest.ts │ │ ├── stateJsx.test.tsx │ │ ├── stateJsx.tsx │ │ ├── test-helpers │ │ │ ├── TestErrorBoundary.tsx │ │ │ └── pipeableStreamToObservable.ts │ │ └── useStateObservable.ts │ ├── tsconfig-build.json │ └── tsconfig.json ├── dom │ ├── README.md │ ├── dist │ │ ├── index.cjs │ │ └── index.js │ ├── package.json │ ├── src │ │ ├── batchUpdates.test.tsx │ │ ├── batchUpdates.ts │ │ └── index.tsx │ ├── tsconfig-build.json │ └── tsconfig.json └── utils │ ├── README.md │ ├── dist │ ├── index.cjs │ └── index.js │ ├── package.json │ ├── src │ ├── combineKeys.test.ts │ ├── combineKeys.ts │ ├── contextBinder.test.tsx │ ├── contextBinder.ts │ ├── createKeyedSignal.spec.ts │ ├── createKeyedSignal.ts │ ├── createListener.ts │ ├── createSignal.spec.ts │ ├── createSignal.ts │ ├── index.tsx │ ├── internal-utils.ts │ ├── mergeWithKey.spec.ts │ ├── mergeWithKey.ts │ ├── partitionByKey.test.ts │ ├── partitionByKey.ts │ ├── selfDependent.test.ts │ ├── selfDependent.ts │ ├── suspend.test.ts │ ├── suspend.ts │ ├── suspended.test.ts │ ├── suspended.ts │ ├── switchMapSuspended.test.ts │ ├── switchMapSuspended.ts │ ├── toKeySet.test.ts │ └── toKeySet.ts │ ├── tsconfig-build.json │ └── tsconfig.json └── vitest.config.ts /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "contributors": [ 8 | { 9 | "login": "josepot", 10 | "name": "Josep M Sobrepere", 11 | "avatar_url": "https://avatars1.githubusercontent.com/u/8620144?v=4", 12 | "profile": "https://github.com/josepot", 13 | "contributions": [ 14 | "code", 15 | "ideas", 16 | "maintenance", 17 | "test", 18 | "review", 19 | "doc", 20 | "infra" 21 | ] 22 | }, 23 | { 24 | "login": "voliva", 25 | "name": "Víctor Oliva", 26 | "avatar_url": "https://avatars2.githubusercontent.com/u/5365487?v=4", 27 | "profile": "https://github.com/voliva", 28 | "contributions": [ 29 | "ideas", 30 | "review", 31 | "code", 32 | "test", 33 | "doc" 34 | ] 35 | }, 36 | { 37 | "login": "clayforthcarr", 38 | "name": "Ed", 39 | "avatar_url": "https://avatars3.githubusercontent.com/u/6012083?v=4", 40 | "profile": "http://www.clayforthcarr.com", 41 | "contributions": [ 42 | "design" 43 | ] 44 | }, 45 | { 46 | "login": "pgrimaud", 47 | "name": "Pierre Grimaud", 48 | "avatar_url": "https://avatars1.githubusercontent.com/u/1866496?v=4", 49 | "profile": "https://github.com/pgrimaud", 50 | "contributions": [ 51 | "doc" 52 | ] 53 | }, 54 | { 55 | "login": "bhavesh-desai-scratch", 56 | "name": "Bhavesh Desai", 57 | "avatar_url": "https://avatars3.githubusercontent.com/u/15194540?v=4", 58 | "profile": "https://github.com/bhavesh-desai-scratch", 59 | "contributions": [ 60 | "review", 61 | "doc", 62 | "test" 63 | ] 64 | }, 65 | { 66 | "login": "mattmischuk", 67 | "name": "Matt Mischuk", 68 | "avatar_url": "https://avatars1.githubusercontent.com/u/3485831?v=4", 69 | "profile": "https://m1x.io", 70 | "contributions": [ 71 | "doc" 72 | ] 73 | }, 74 | { 75 | "login": "rikoe", 76 | "name": "Riko Eksteen", 77 | "avatar_url": "https://avatars1.githubusercontent.com/u/3295115?v=4", 78 | "profile": "https://github.com/rikoe", 79 | "contributions": [ 80 | "infra", 81 | "review", 82 | "doc", 83 | "code", 84 | "ideas" 85 | ] 86 | }, 87 | { 88 | "login": "hoclun-rigsep", 89 | "name": "hoclun-rigsep", 90 | "avatar_url": "https://avatars.githubusercontent.com/u/20741358?v=4", 91 | "profile": "https://github.com/hoclun-rigsep", 92 | "contributions": [ 93 | "doc", 94 | "ideas" 95 | ] 96 | }, 97 | { 98 | "login": "skve", 99 | "name": "Luke Shiels", 100 | "avatar_url": "https://avatars.githubusercontent.com/u/47612057?v=4", 101 | "profile": "https://github.com/skve", 102 | "contributions": [ 103 | "bug", 104 | "code" 105 | ] 106 | }, 107 | { 108 | "login": "rveciana", 109 | "name": "Roger Veciana i Rovira", 110 | "avatar_url": "https://avatars.githubusercontent.com/u/2832885?v=4", 111 | "profile": "http://geoexamples.com", 112 | "contributions": [ 113 | "maintenance" 114 | ] 115 | } 116 | ], 117 | "contributorsPerLine": 7, 118 | "projectName": "react-rxjs", 119 | "projectOwner": "re-rxjs", 120 | "repoType": "github", 121 | "repoHost": "https://github.com", 122 | "skipCi": true, 123 | "commitType": "docs", 124 | "commitConvention": "angular" 125 | } 126 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | push: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v1 16 | with: 17 | node-version: "16" 18 | - run: npm ci 19 | - run: npm run lint 20 | 21 | - name: Build & Bundlewatch 22 | uses: jackyef/bundlewatch-gh-action@master 23 | env: 24 | CI_BRANCH_BASE: main 25 | with: 26 | build-script: npm run build 27 | bundlewatch-github-token: ${{ secrets.BUNDLEWATCH_GITHUB_TOKEN }} 28 | 29 | - name: Tests 30 | run: npm test 31 | 32 | - name: Code Coverage 33 | uses: codecov/codecov-action@v3 34 | with: 35 | files: ./packages/core/coverage/lcov.info,./packages/utils/coverage/lcov.info,./packages/dom/coverage/lcov.info 36 | fail_ci_if_error: true 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | coverage 5 | .cache 6 | packages/core/dist/** 7 | !packages/core/dist/index.cjs 8 | !packages/core/dist/index.js 9 | packages/utils/dist/** 10 | !packages/utils/dist/index.cjs 11 | !packages/utils/dist/index.js 12 | packages/dom/dist/** 13 | !packages/dom/dist/index.cjs 14 | !packages/dom/dist/index.js 15 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.10.8 (2025-01-14) 2 | 3 | - Update dependencies to support React 19 (#321) 4 | 5 | ## 0.10.7 (2023-08-04) 6 | 7 | - Add getServerSnapshot, fix loop on SSR Subscribe (#306) 8 | 9 | ## 0.10.6 (2023-07-12) 10 | 11 | - fix(webpack build): default condition should be last one (#304) 12 | 13 | ## 0.10.5 (2023-07-11) 14 | 15 | - fix: types not read when moduleResolution is not "node" (#302) 16 | 17 | ### utils@0.9.6 18 | 19 | - fix(selfDependent): prevent subject from being closed after unsubscription (#283) 20 | 21 | ## 0.10.4 (2023-03-11) 22 | 23 | - fix: can't derive a StateObservable with takeUntil 24 | 25 | ## 0.10.3 (2022-09-09) 26 | 27 | - fix: avoid errors on unmounted Suspense components 28 | 29 | ## 0.10.2 (2022-09-09) 30 | 31 | - fix: pipeState also enhanced as a React element (#282) 32 | 33 | ## 0.10.1 (2022-09-09) 34 | 35 | - fix: re-export types correctly from @rx-state/core 36 | 37 | ### utils 38 | 39 | - chore: rename `selfDependant` to `selfDependent` (#272) 40 | 41 | ## 0.10.0 (2022-09-09) 42 | 43 | - StateObservables as JSX Elements. 44 | 45 | StateObservables are now also JSX Elements, which lets you use them directly as children of other components. 46 | 47 | ```tsx 48 | const count$ = state(interval(1000), 0) 49 | 50 | const App = () => { 51 | const count = useStateObservable(count$) 52 | 53 | return
{count}
54 | } 55 | 56 | // Becomes 57 | 58 | const App = () => { 59 | return
{count$}
60 | } 61 | ``` 62 | 63 | - `.pipeState()`, `withDefault()` 64 | 65 | StateObservables now have a shorthand method `.pipeState(...args)` which works as RxJS `.pipe(`, but it wraps the result into a new state. 66 | 67 | ```ts 68 | const newState$ = state( 69 | parent$.pipe( 70 | map(...) 71 | ) 72 | ) 73 | 74 | // Becomes 75 | const newState$ = parent$.pipeState( 76 | map(...) 77 | ) 78 | ``` 79 | 80 | `withDefault(value)` is an operator that creates a DefaultedStateObservable. It can be used at the end of `pipeState` to set the default value for that one. 81 | 82 | ```ts 83 | const newState$ = state( 84 | parent$.pipe( 85 | map(...) 86 | ), 87 | "defaultVal" 88 | ) 89 | 90 | // Becomes 91 | const newState$ = parent$.pipeState( 92 | map(...), 93 | withDefault("defaultVal") 94 | ) 95 | ``` 96 | 97 | - Add additional argument on factory observables to prevent using them in incompatible HOF. 98 | 99 | Previously factory functions had the same signature that was passed into the function. You can use them in higher-order-functions and Typescript will think it's valid: 100 | 101 | ```ts 102 | const user$ = state((id: string) => ...); 103 | 104 | const selectedUser$ = state( 105 | selectedId$.pipe( 106 | switchMap(user$) 107 | ) 108 | ); 109 | ``` 110 | 111 | This is problematic because `switchMap` also passes in the number of elements emitted so far as the second argument, and the parametric state will then understand each call as a new instance. 112 | 113 | Now `user$` will have a typescript signature that will prevent it from being used into places that give more parameters than it has, so Typescript will flag this as an error. 114 | 115 | - `sinkSuspense()`, `liftSuspense()` 116 | 117 | These two new operators help deal with SUSPENSE values on the streams, which is useful when the meaning of SUSPENSE is that everything needs to be reset. 118 | 119 | `sinkSuspense()` is an operator that when it receives a SUSPENSE, it will throw it as an error down the stream, which resets all of the observables down below. It will then hold the subscription to the upstream, waiting for a resubscription to happen immediately. If it doesn't happen, then it will unsubscribe from upstream. 120 | 121 | `liftSuspense()` is an operator that when it receives SUSPENSE as an error, it will immediately resubscribe to its upstream, and emit SUSPENSE as a value. 122 | 123 | This allows to avoid dealing with SUSPENSE on the streams that are in-between the one that generates SUSPENSE and the one that needs to receive it. 124 | 125 | ```ts 126 | const account$ = accountSwitch$.pipe(switchMapSuspended((v) => fetchAccount(v))) 127 | 128 | const posts$ = account$.pipe( 129 | switchMap((v) => (v === SUSPENSE ? of(SUSPENSE) : fetchPosts(v))), 130 | ) 131 | 132 | /// with sinkSuspense 133 | const account$ = accountSwitch$.pipe( 134 | switchMapSuspended((v) => fetchAccount(v)), 135 | sinkSuspense(), 136 | ) 137 | 138 | const posts$ = account$.pipe(switchMap((v) => fetchPosts(v))) 139 | ``` 140 | 141 | `useStateObservable` is already fitted with `liftSuspense()`, so there's no need to call it on the StateObservables that are to be used in components. 142 | 143 | It's very important to remember that `sinkSuspense` is throwing SUSPENSE values as errors, which means that subscriptions will get closed, in ways that are not always obvious. In most of the cases, it can be solved by using `liftSuspense()`, dealing with that value, and calling `sinkSuspense()` again. Use at your own risk. 144 | 145 | ### Fixes 146 | 147 | - Fix observable of promises triggering suspense. 148 | - Fix observables emitting synchronous completes triggering NoSubscribersError on Subscribe 149 | 150 | ## 0.9.8 (2022-06-24) 151 | 152 | - Fix asynchronous errors on Subscribe not getting caught on ErrorBoundaries. 153 | 154 | ## 0.9.7 (2022-06-14) 155 | 156 | - Fix Subscribe error on immediate unmount when running in React18 StrictMode 157 | 158 | ## 0.9.6 (2022-04-29) 159 | 160 | - RemoveSubscribe 161 | 162 | New component that prevents its children from using a parent `` to manage their subscriptions. 163 | 164 | - improve SUSPENSE types 165 | 166 | ## 0.9.5 (2022-04-11) 167 | 168 | - upgrade dependencies (React 18) 169 | 170 | ## 0.9.4 (2022-04-04) 171 | 172 | - utils: `toKeySet()` 173 | 174 | Operator that turns an `Observable>` into an `Observable>` 175 | 176 | - fix useStateObservable on StateObservables that emit synchronously without default value. 177 | - fix partitionByKey not emitting synchronously when a new group came in. 178 | 179 | ## 0.9.3 (2022-03-30) 180 | 181 | - utils: Improve performance of `partitionByKey` with a big number of elements (#232) 182 | 183 | BREAKING CHANGE: partitionByKey's key stream now returns deltas `Observable>` instead of list of keys `Observable`. Shouldn't have an impact if the stream was used directly into `combineKeys`. 184 | 185 | - fix Subscribe running in react18 StrictMode (#249) 186 | 187 | ## 0.9.2 (2022-03-29) 188 | 189 | - fix React Native build 190 | 191 | ## 0.9.1 (2022-03-27) 192 | 193 | - fix types for DefaultedStateObservable 194 | - fix compile error on Next.js 12 195 | 196 | ## 0.9.0 (2022-03-20) 197 | 198 | - `state()`, `useStateObservable()` 199 | 200 | There's a different way of creating and consuming observables now. 201 | 202 | Instead of calling `bind` which returns a hook and a shared observable, `state()` just returns the shared observable. This can be consumed in the components by using the hook `useStateObservable()`. 203 | 204 | ```tsx 205 | const [useUser, user$] = bind(fetchUser()); 206 | 207 | const App = () => { 208 | const user = useUser(); 209 | 210 | ... 211 | } 212 | 213 | // Becomes 214 | const user$ = state(fetchUser()); 215 | 216 | const App = () => { 217 | const user = useStateObservable(user$); 218 | 219 | ... 220 | } 221 | ``` 222 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers 6 | pledge to making participation in our project and our community a harassment-free experience for 7 | everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity 8 | and expression, level of experience, education, socio-economic status, nationality, personal 9 | appearance, race, religion, or sexual identity and orientation. 10 | 11 | ## Our Standards 12 | 13 | Examples of behavior that contributes to creating a positive environment include: 14 | 15 | - Using welcoming and inclusive language 16 | - Being respectful of differing viewpoints and experiences 17 | - Gracefully accepting constructive criticism 18 | - Focusing on what is best for the community 19 | - Showing empathy towards other community members 20 | 21 | Examples of unacceptable behavior by participants include: 22 | 23 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 24 | - Trolling, insulting/derogatory comments, and personal or political attacks 25 | - Public or private harassment 26 | - Publishing others' private information, such as a physical or electronic address, without explicit 27 | permission 28 | - Other conduct which could reasonably be considered inappropriate in a professional setting 29 | 30 | ## Our Responsibilities 31 | 32 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are 33 | expected to take appropriate and fair corrective action in response to any instances of unacceptable 34 | behavior. 35 | 36 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, 37 | code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or 38 | to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, 39 | threatening, offensive, or harmful. 40 | 41 | ## Scope 42 | 43 | This Code of Conduct applies both within project spaces and in public spaces when an individual is 44 | representing the project or its community. Examples of representing a project or community include 45 | using an official project e-mail address, posting via an official social media account, or acting as 46 | an appointed representative at an online or offline event. Representation of a project may be 47 | further defined and clarified by project maintainers. 48 | 49 | ## Enforcement 50 | 51 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting 52 | the project team at josep@sbrpr.com. All complaints will be reviewed and investigated and 53 | will result in a response that is deemed necessary and appropriate to the circumstances. The project 54 | team is obligated to maintain confidentiality with regard to the reporter of an incident. Further 55 | details of specific enforcement policies may be posted separately. 56 | 57 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face 58 | temporary or permanent repercussions as determined by other members of the project's leadership. 59 | 60 | ## Attribution 61 | 62 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at 63 | https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 64 | 65 | [homepage]: https://www.contributor-covenant.org 66 | 67 | For answers to common questions about this code of conduct, see 68 | https://www.contributor-covenant.org/faq 69 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Josep M Sobrepere 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React-RxJS Logo React-RxJS 2 | 3 | 4 | [![Build Status](https://img.shields.io/github/workflow/status/re-rxjs/react-rxjs/CI?style=flat-square)](https://github.com/re-rxjs/react-rxjs/actions) 5 | [![codecov](https://img.shields.io/codecov/c/github/re-rxjs/react-rxjs.svg?style=flat-square)](https://codecov.io/gh/re-rxjs/react-rxjs) 6 | [![version](https://img.shields.io/npm/v/@react-rxjs/core.svg?style=flat-square)](https://www.npmjs.com/package/@react-rxjs/core) 7 | [![MIT License](https://img.shields.io/npm/l/react-rxjs.svg?style=flat-square)](https://github.com/re-rxjs/react-rxjs/blob/main/LICENSE) 8 | [![All Contributors](https://img.shields.io/badge/all_contributors-7-orange.svg?style=flat-square)](#contributors-) 9 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) 10 | [![Code of Conduct](https://img.shields.io/badge/code%20of-conduct-ff69b4.svg?style=flat-square)](https://github.com/re-rxjs/react-rxjs/blob/main/CODE_OF_CONDUCT.md) 11 | 12 | 13 | React-RxJS is a library that offers [React](https://reactjs.org/) bindings for [RxJS](https://rxjs.dev/) 14 | 15 | Please visit the website: https://react-rxjs.org 16 | 17 | ## Main features 18 | 19 | - :cyclone: Truly Reactive 20 | - :zap: Highly performant and free of memory-leaks 21 | - :twisted_rightwards_arrows: First class support for React Suspense and [ready for Concurrent Mode](https://github.com/dai-shi/will-this-react-global-state-work-in-concurrent-mode#results) 22 | - :scissors: Decentralized and composable, thus enabling optimal code-splitting 23 | - :microscope: [Tiny and tree-shakeable](https://bundlephobia.com/result?p=@react-rxjs/core) 24 | - :muscle: Supports TypeScript 25 | 26 | ## Installation 27 | 28 | npm install @react-rxjs/core 29 | 30 | ## Contributors ✨ 31 | 32 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 |
Josep M Sobrepere
Josep M Sobrepere

💻 🤔 🚧 ⚠️ 👀 📖 🚇
Víctor Oliva
Víctor Oliva

🤔 👀 💻 ⚠️ 📖
Ed
Ed

🎨
Pierre Grimaud
Pierre Grimaud

📖
Bhavesh Desai
Bhavesh Desai

👀 📖 ⚠️
Matt Mischuk
Matt Mischuk

📖
Riko Eksteen
Riko Eksteen

🚇 👀 📖 💻 🤔
hoclun-rigsep
hoclun-rigsep

📖 🤔
Luke Shiels
Luke Shiels

🐛 💻
Roger Veciana i Rovira
Roger Veciana i Rovira

🚧
55 | 56 | 57 | 58 | 59 | 60 | 61 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 62 | -------------------------------------------------------------------------------- /assets/logo-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-rxjs/react-rxjs/330f4c329f635c577e39655bd46c0d80a13f3a41/assets/logo-128.png -------------------------------------------------------------------------------- /assets/logo-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-rxjs/react-rxjs/330f4c329f635c577e39655bd46c0d80a13f3a41/assets/logo-256.png -------------------------------------------------------------------------------- /assets/logo-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-rxjs/react-rxjs/330f4c329f635c577e39655bd46c0d80a13f3a41/assets/logo-48.png -------------------------------------------------------------------------------- /assets/logo-64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/re-rxjs/react-rxjs/330f4c329f635c577e39655bd46c0d80a13f3a41/assets/logo-64.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "license": "MIT", 4 | "workspaces": [ 5 | "packages/*" 6 | ], 7 | "bundlewatch": { 8 | "ci": { 9 | "trackBranches": [ 10 | "main" 11 | ] 12 | }, 13 | "files": [ 14 | { 15 | "path": "./packages/core/dist/core.cjs.production.min.js", 16 | "maxSize": "6 kB", 17 | "compression": "none" 18 | }, 19 | { 20 | "path": "./packages/dom/dist/dom.cjs.production.min.js", 21 | "maxSize": "2 kB", 22 | "compression": "none" 23 | }, 24 | { 25 | "path": "./packages/utils/dist/utils.cjs.production.min.js", 26 | "maxSize": "5 kB", 27 | "compression": "none" 28 | } 29 | ] 30 | }, 31 | "scripts": { 32 | "build": "npm run build --workspace=@react-rxjs/core && npm run build --workspace=@react-rxjs/utils --workspace=@react-rxjs/dom", 33 | "lint": "npm run lint --workspaces", 34 | "format": "npm run format --workspaces", 35 | "test": "npm run test --workspaces", 36 | "prepare": "husky install" 37 | }, 38 | "prettier": { 39 | "printWidth": 80, 40 | "semi": false, 41 | "trailingComma": "all" 42 | }, 43 | "devDependencies": { 44 | "@babel/preset-env": "^7.26.0", 45 | "@babel/preset-typescript": "^7.26.0", 46 | "@testing-library/react": "^16.1.0", 47 | "@types/node": "^22.10.5", 48 | "@types/react": "^19.0.4", 49 | "@types/react-dom": "^19.0.2", 50 | "@vitest/coverage-v8": "^2.1.8", 51 | "esbuild": "^0.24.2", 52 | "expose-gc": "^1.0.0", 53 | "husky": ">=8.0.3", 54 | "jest-environment-jsdom": "^29.7.0", 55 | "jsdom": "^26.0.0", 56 | "lint-staged": "^15.3.0", 57 | "prettier": "^3.4.2", 58 | "react": "^19.0.0", 59 | "react-dom": "^19.0.0", 60 | "react-test-renderer": "^19.0.0", 61 | "rxjs": "^7.8.1", 62 | "tslib": "^2.8.1", 63 | "typescript": "^5.7.3", 64 | "vitest": "^2.1.8" 65 | }, 66 | "lint-staged": { 67 | "*.{js,jsx,ts,tsx,json,md}": "prettier --write" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /packages/core/README.md: -------------------------------------------------------------------------------- 1 | # @react-rxjs/core 2 | 3 | Please visit the website: https://react-rxjs.org 4 | 5 | ## Installation 6 | 7 | npm install @react-rxjs/core 8 | -------------------------------------------------------------------------------- /packages/core/babel.config.js: -------------------------------------------------------------------------------- 1 | // Only used by Jest 2 | module.exports = { 3 | presets: [ 4 | [ 5 | "@babel/preset-env", 6 | { useBuiltIns: "entry", corejs: "2", targets: { node: "current" } }, 7 | ], 8 | "@babel/preset-typescript", 9 | ], 10 | plugins: [ 11 | function () { 12 | return { 13 | visitor: { 14 | MetaProperty(path) { 15 | path.replaceWithSourceString("process") 16 | }, 17 | }, 18 | } 19 | }, 20 | ], 21 | } 22 | -------------------------------------------------------------------------------- /packages/core/cjsBuild.js: -------------------------------------------------------------------------------- 1 | const path = require("path") 2 | 3 | const isProd = process.argv[2] === "--prod" 4 | 5 | const fixCjsPlugin = { 6 | name: "fixCJS", 7 | setup(build) { 8 | build.onResolve({ filter: /useSyncExternalStore/ }, (args) => { 9 | return { 10 | path: path.join(args.resolveDir, args.path + "Cjs.ts"), 11 | } 12 | }) 13 | }, 14 | } 15 | 16 | require("esbuild") 17 | .build({ 18 | entryPoints: ["src/index.tsx"], 19 | bundle: true, 20 | outfile: isProd 21 | ? "./dist/core.cjs.production.min.js" 22 | : "./dist/core.cjs.development.js", 23 | target: "es2015", 24 | minify: isProd, 25 | external: ["react", "rxjs", "@rx-state/core", "use-sync-external-store"], 26 | format: "cjs", 27 | sourcemap: true, 28 | plugins: [fixCjsPlugin], 29 | }) 30 | .catch((error) => { 31 | console.error(error) 32 | process.exit(1) 33 | }) 34 | -------------------------------------------------------------------------------- /packages/core/dist/index.cjs: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | if (process.env.NODE_ENV === "production") { 4 | module.exports = require("./core.cjs.production.min.js") 5 | } else { 6 | module.exports = require("./core.cjs.development.js") 7 | } 8 | -------------------------------------------------------------------------------- /packages/core/dist/index.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | if (process.env.NODE_ENV === "production") { 4 | module.exports = require("./core.cjs.production.min.js") 5 | } else { 6 | module.exports = require("./core.cjs.development.js") 7 | } 8 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.10.8", 3 | "repository": { 4 | "type": "git", 5 | "url": "git+https://github.com/re-rxjs/react-rxjs.git" 6 | }, 7 | "license": "MIT", 8 | "sideEffects": false, 9 | "exports": { 10 | ".": { 11 | "node": { 12 | "module": "./dist/core.es2017.js", 13 | "import": "./dist/core.es2019.mjs", 14 | "require": "./dist/index.cjs" 15 | }, 16 | "types": "./dist/index.d.ts", 17 | "default": "./dist/core.es2017.js" 18 | }, 19 | "./package.json": "./package.json" 20 | }, 21 | "module": "./dist/core.es2017.js", 22 | "main": "./dist/index.js", 23 | "types": "./dist/index.d.ts", 24 | "files": [ 25 | "dist" 26 | ], 27 | "scripts": { 28 | "build": "npm run build:ts && npm run build:esm2017 && npm run build:esm2019 && npm run build:cjs:dev && npm run build:cjs:prod", 29 | "build:esm2019": "esbuild src/index.tsx --bundle --outfile=./dist/core.es2019.mjs --target=es2019 --external:react --external:rxjs --external:@rx-state/core --external:use-sync-external-store --format=esm --sourcemap", 30 | "build:esm2017": "esbuild src/index.tsx --bundle --outfile=./dist/core.es2017.js --target=es2017 --external:react --external:rxjs --external:@rx-state/core --external:use-sync-external-store --format=esm --sourcemap", 31 | "build:cjs:dev": "node cjsBuild.js", 32 | "build:cjs:prod": "node cjsBuild.js --prod", 33 | "build:ts": "tsc -p ./tsconfig-build.json --outDir ./dist --skipLibCheck --emitDeclarationOnly", 34 | "test": "vitest run --coverage", 35 | "test:watch": "vitest watch", 36 | "lint": "prettier --check README.md \"src/**/*.{js,jsx,ts,tsx,json,md}\"", 37 | "format": "prettier --write README.md \"src/**/*.{js,jsx,ts,tsx,json,md}\"", 38 | "prepack": "npm run build" 39 | }, 40 | "peerDependencies": { 41 | "react": ">=16.8.0", 42 | "rxjs": ">=7" 43 | }, 44 | "prettier": { 45 | "printWidth": 80, 46 | "semi": false, 47 | "trailingComma": "all" 48 | }, 49 | "name": "@react-rxjs/core", 50 | "authors": [ 51 | "Josep M Sobrepere (https://github.com/josepot)", 52 | "Victor Oliva (https://github.com/voliva)" 53 | ], 54 | "dependencies": { 55 | "@rx-state/core": "0.1.4", 56 | "use-sync-external-store": "^1.4.0" 57 | }, 58 | "devDependencies": { 59 | "@types/use-sync-external-store": "^0.0.6" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /packages/core/src/Subscribe.test.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | EmptyObservableError, 3 | NoSubscribersError, 4 | sinkSuspense, 5 | state, 6 | SUSPENSE, 7 | } from "@rx-state/core" 8 | import { act, render, screen } from "@testing-library/react" 9 | import React, { StrictMode, useEffect, useState } from "react" 10 | import { renderToPipeableStream } from "react-dom/server" 11 | import { 12 | defer, 13 | EMPTY, 14 | lastValueFrom, 15 | NEVER, 16 | Observable, 17 | of, 18 | startWith, 19 | Subject, 20 | } from "rxjs" 21 | import { describe, expect, it, vi } from "vitest" 22 | import { bind, Subscribe as OriginalSubscribe, RemoveSubscribe } from "./" 23 | import { pipeableStreamToObservable } from "./test-helpers/pipeableStreamToObservable" 24 | import { TestErrorBoundary } from "./test-helpers/TestErrorBoundary" 25 | import { useStateObservable } from "./useStateObservable" 26 | 27 | const wait = (ms: number) => new Promise((res) => setTimeout(res, ms)) 28 | 29 | const Subscribe = (props: any) => { 30 | return ( 31 | 32 | 33 | 34 | ) 35 | } 36 | 37 | describe("Subscribe", () => { 38 | describe("Subscribe with source$", () => { 39 | it("renders the sync emitted value on a StateObservable without default value", () => { 40 | const test$ = state(EMPTY.pipe(startWith("there!"))) 41 | const useTest = () => useStateObservable(test$) 42 | 43 | const Test: React.FC = () => <>Hello {useTest()} 44 | 45 | const TestSubscribe: React.FC = () => ( 46 | 47 | 48 | 49 | ) 50 | 51 | const { unmount } = render() 52 | 53 | expect(screen.queryByText("Hello there!")).not.toBeNull() 54 | 55 | unmount() 56 | }) 57 | it("subscribes to the provided observable and remains subscribed until it's unmounted", () => { 58 | let nSubscriptions = 0 59 | const [useNumber, number$] = bind( 60 | new Observable(() => { 61 | nSubscriptions++ 62 | return () => { 63 | nSubscriptions-- 64 | } 65 | }), 66 | ) 67 | 68 | const Number: React.FC = () => <>{useNumber()} 69 | const TestSubscribe: React.FC = () => ( 70 | 71 | 72 | 73 | ) 74 | 75 | expect(nSubscriptions).toBe(0) 76 | 77 | const { unmount } = render() 78 | 79 | expect(nSubscriptions).toBe(1) 80 | 81 | unmount() 82 | expect(nSubscriptions).toBe(0) 83 | }) 84 | 85 | it("doesn't render its content until it has subscribed to a new source", () => { 86 | let nSubscriptions = 0 87 | let errored = false 88 | const [useInstance, instance$] = bind((id: number) => { 89 | if (id === 0) { 90 | return of(0) 91 | } 92 | return defer(() => { 93 | nSubscriptions++ 94 | return of(1) 95 | }) 96 | }) 97 | 98 | const Child = ({ id }: { id: number }) => { 99 | const value = useInstance(id) 100 | 101 | if (id !== 0 && nSubscriptions === 0) { 102 | errored = true 103 | } 104 | 105 | return <>{value} 106 | } 107 | const { rerender } = render( 108 | 109 | 110 | , 111 | ) 112 | expect(nSubscriptions).toBe(0) 113 | expect(errored).toBe(false) 114 | 115 | rerender( 116 | 117 | 118 | , 119 | ) 120 | expect(nSubscriptions).toBe(1) 121 | expect(errored).toBe(false) 122 | 123 | rerender( 124 | 125 | 126 | , 127 | ) 128 | expect(nSubscriptions).toBe(2) 129 | expect(errored).toBe(false) 130 | }) 131 | 132 | it("prevents the issue of stale data when switching keys", () => { 133 | const [useInstance, instance$] = bind((id: number) => of(id)) 134 | 135 | const Child = ({ 136 | id, 137 | initialValue, 138 | }: { 139 | id: number 140 | initialValue: number 141 | }) => { 142 | const [value] = useState(initialValue) 143 | 144 | return ( 145 | <> 146 |
{id}
147 |
{value}
148 | 149 | ) 150 | } 151 | 152 | const Parent = ({ id }: { id: number }) => { 153 | const value = useInstance(id) 154 | 155 | return 156 | } 157 | const { rerender, getByTestId } = render( 158 | 159 | 160 | , 161 | ) 162 | 163 | rerender( 164 | 165 | 166 | , 167 | ) 168 | expect(getByTestId("id").textContent).toBe("1") 169 | expect(getByTestId("value").textContent).toBe("1") 170 | 171 | const instanceTwoSubs = instance$(2).subscribe() 172 | rerender( 173 | 174 | 175 | , 176 | ) 177 | expect(getByTestId("id").textContent).toBe("2") 178 | expect(getByTestId("value").textContent).toBe("2") 179 | instanceTwoSubs.unsubscribe() 180 | }) 181 | 182 | it("lifts the effects of the source$ prop", () => { 183 | const subject$ = new Subject() 184 | const test$ = state(subject$.pipe(sinkSuspense())) 185 | 186 | const { unmount } = render() 187 | 188 | expect(test$.getRefCount()).toBe(1) 189 | 190 | act(() => subject$.next(SUSPENSE)) 191 | expect(test$.getRefCount()).toBe(1) 192 | 193 | act(() => subject$.next(1)) 194 | expect(test$.getRefCount()).toBe(1) 195 | 196 | unmount() 197 | }) 198 | }) 199 | describe("Subscribe without source$", () => { 200 | it("subscribes to the provided observable and remains subscribed until it's unmounted", () => { 201 | let nSubscriptions = 0 202 | const [useNumber] = bind( 203 | new Observable(() => { 204 | nSubscriptions++ 205 | return () => { 206 | nSubscriptions-- 207 | } 208 | }), 209 | ) 210 | 211 | const Number: React.FC = () => <>{useNumber()} 212 | const TestSubscribe: React.FC = () => ( 213 | 214 | 215 | 216 | ) 217 | 218 | expect(nSubscriptions).toBe(0) 219 | 220 | const { unmount } = render() 221 | 222 | expect(nSubscriptions).toBe(1) 223 | 224 | unmount() 225 | expect(nSubscriptions).toBe(0) 226 | }) 227 | 228 | it("doesn't render its content until it has subscribed to a new source", () => { 229 | let nSubscriptions = 0 230 | let errored = false 231 | const [useInstance] = bind((id: number) => { 232 | if (id === 0) { 233 | return of(0) 234 | } 235 | return defer(() => { 236 | nSubscriptions++ 237 | return of(1) 238 | }) 239 | }) 240 | 241 | const Child = ({ id }: { id: number }) => { 242 | const value = useInstance(id) 243 | 244 | if (id !== 0 && nSubscriptions === 0) { 245 | errored = true 246 | } 247 | 248 | return <>{value} 249 | } 250 | const { rerender } = render( 251 | 252 | 253 | , 254 | ) 255 | expect(nSubscriptions).toBe(0) 256 | expect(errored).toBe(false) 257 | 258 | rerender( 259 | 260 | 261 | , 262 | ) 263 | expect(nSubscriptions).toBe(1) 264 | expect(errored).toBe(false) 265 | }) 266 | 267 | it("prevents the issue of stale data when switching keys", () => { 268 | const [useInstance] = bind((id: number) => of(id)) 269 | 270 | const Child = ({ 271 | id, 272 | initialValue, 273 | }: { 274 | id: number 275 | initialValue: number 276 | }) => { 277 | const [value] = useState(initialValue) 278 | 279 | return ( 280 | <> 281 |
{id}
282 |
{value}
283 | 284 | ) 285 | } 286 | 287 | const Parent = ({ id }: { id: number }) => { 288 | const value = useInstance(id) 289 | 290 | return 291 | } 292 | const { rerender, getByTestId } = render( 293 | 294 | 295 | , 296 | ) 297 | 298 | rerender( 299 | 300 | 301 | , 302 | ) 303 | expect(getByTestId("id").textContent).toBe("1") 304 | expect(getByTestId("value").textContent).toBe("1") 305 | }) 306 | 307 | it("on StrictMode: it doesn't crash if the component immediately unmounts", () => { 308 | function App() { 309 | const [switched, setSwitched] = useState(false) 310 | 311 | useEffect(() => { 312 | setSwitched(true) 313 | }, []) 314 | 315 | return ( 316 |
317 | {switched ? : } 318 |
319 | ) 320 | } 321 | 322 | const ProblematicComponent = () => { 323 | return 324 | } 325 | const SwitchToComponent = () => { 326 | return
All good
327 | } 328 | 329 | let hasError = false 330 | 331 | render( 332 | { 334 | hasError = true 335 | }} 336 | > 337 | 338 | , 339 | ) 340 | 341 | expect(hasError).toBe(false) 342 | }) 343 | 344 | it("allows async errors to be caught in error boundaries with suspense, without using source$", async () => { 345 | const [useError] = bind( 346 | new Observable((obs) => { 347 | setTimeout(() => obs.error("controlled error"), 10) 348 | }), 349 | ) 350 | 351 | const ErrorComponent = () => { 352 | const value = useError() 353 | return <>{value} 354 | } 355 | 356 | const errorCallback = vi.fn() 357 | const { unmount } = render( 358 | 359 | Loading...}> 360 | 361 | 362 | , 363 | ) 364 | 365 | await act(async () => { 366 | await wait(100) 367 | }) 368 | 369 | expect(errorCallback).toHaveBeenCalledWith( 370 | "controlled error", 371 | expect.any(Object), 372 | ) 373 | unmount() 374 | }) 375 | 376 | it("propagates the EmptyObservable error if a stream completes synchronously", async () => { 377 | const globalErrors = vi.spyOn(console, "error") 378 | globalErrors.mockImplementation((v) => v) 379 | 380 | const [useEmpty] = bind(() => EMPTY) 381 | 382 | const ErrorComponent = () => { 383 | useEmpty() 384 | return null 385 | } 386 | 387 | const errorCallback = vi.fn() 388 | const { unmount } = render( 389 | 390 | Loading...}> 391 | 392 | 393 | , 394 | ) 395 | 396 | // Can't have NoSubscribersError 397 | // Can't have "Cannot update component (`%s`) while rendering a different component" 398 | globalErrors.mock.calls.forEach(([errorMessage]) => { 399 | expect(errorMessage).not.toContain(NoSubscribersError.name) 400 | expect(errorMessage).not.toContain( 401 | "Cannot update a component (`%s`) while rendering a different component", 402 | ) 403 | }) 404 | globalErrors.mockRestore() 405 | 406 | // Must have EmptyObservableError 407 | expect(errorCallback.mock.calls.length).toBe(1) 408 | expect(errorCallback.mock.calls[0][0]).toBeInstanceOf( 409 | EmptyObservableError, 410 | ) 411 | 412 | unmount() 413 | }) 414 | 415 | it("lifts the effects of observables passed through context", () => { 416 | const subject$ = new Subject() 417 | let innerSubs = 0 418 | const test$ = state( 419 | defer(() => { 420 | innerSubs++ 421 | return subject$ 422 | }).pipe(sinkSuspense()), 423 | ) 424 | 425 | const Child = () => <>{useStateObservable(test$)} 426 | 427 | const { unmount } = render( 428 | 429 | 430 | , 431 | ) 432 | 433 | expect(test$.getRefCount()).toBe(1) 434 | 435 | act(() => subject$.next(SUSPENSE)) 436 | expect(test$.getRefCount()).toBe(1) 437 | 438 | act(() => subject$.next(1)) 439 | expect(test$.getRefCount()).toBe(1) 440 | 441 | expect(innerSubs).toBe(1) 442 | 443 | unmount() 444 | }) 445 | }) 446 | 447 | describe("On SSR", () => { 448 | // Testing-library doesn't support SSR yet https://github.com/testing-library/react-testing-library/issues/561 449 | 450 | it("Renders the fallback", async () => { 451 | const stream = renderToPipeableStream( 452 | Loading}> 453 |
Content
454 |
, 455 | ) 456 | const result = await lastValueFrom(pipeableStreamToObservable(stream)) 457 | 458 | expect(result).toContain("
Loading
") 459 | expect(result).not.toContain("
Content
") 460 | }) 461 | }) 462 | }) 463 | 464 | describe("RemoveSubscribe", () => { 465 | it("prevents its children from using the parent Subscribe boundary", () => { 466 | const [useValue] = bind(NEVER) 467 | 468 | const ChildrenComponent = () => { 469 | const value = useValue() 470 | return <>{value} 471 | } 472 | 473 | const errorCallback = vi.fn() 474 | render( 475 | errorCallback(e.message)}> 476 | 477 | 478 | 479 | 480 | 481 | , 482 | ) 483 | 484 | expect(errorCallback).toHaveBeenCalledWith("Missing Subscribe!") 485 | }) 486 | }) 487 | -------------------------------------------------------------------------------- /packages/core/src/Subscribe.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useState, 3 | Suspense, 4 | useEffect, 5 | ReactNode, 6 | useRef, 7 | createContext, 8 | useContext, 9 | } from "react" 10 | import { Observable, Subscription } from "rxjs" 11 | import { liftSuspense, StateObservable } from "@rx-state/core" 12 | import { EMPTY_VALUE } from "./internal/empty-value" 13 | 14 | const SubscriptionContext = createContext< 15 | ((src: StateObservable) => void) | null 16 | >(null) 17 | const { Provider } = SubscriptionContext 18 | export const useSubscription = () => useContext(SubscriptionContext) 19 | 20 | const p = Promise.resolve() 21 | const Throw = () => { 22 | throw p 23 | } 24 | 25 | /** 26 | * A React Component that: 27 | * - collects the subscriptions of its children and it unsubscribes them when 28 | * the component unmounts. 29 | * - if a source$ property is used, then it ensures that the subscription to the 30 | * observable will exist before the children gets rendered, and it unsubscribes 31 | * from it when the component unmounts. 32 | * 33 | * If the fallback property is used, then the component will create a Suspense 34 | * boundary with the provided JSX Element, otherwise it will render null until 35 | * the subscription exists. 36 | * 37 | * @param [source$] (=undefined) - Source observable that the Component will 38 | * subscrib to before it renders its children. 39 | * @param [fallback] (=null) - JSX Element to be used by the Suspense boundary. 40 | * 41 | * @remarks This Component doesn't trigger any updates from the source$. 42 | */ 43 | export const Subscribe: React.FC<{ 44 | children?: React.ReactNode | undefined 45 | source$?: Observable 46 | fallback?: NonNullable | null 47 | }> = ({ source$, children, fallback }) => { 48 | const subscriptionRef = useRef< 49 | | { 50 | s: Subscription 51 | u: (source: StateObservable) => void 52 | } 53 | | undefined 54 | >(undefined) 55 | 56 | if (!subscriptionRef.current) { 57 | const s = new Subscription() 58 | subscriptionRef.current = { 59 | s, 60 | u: (src) => { 61 | let error = EMPTY_VALUE 62 | let synchronous = true 63 | s.add( 64 | liftSuspense()(src).subscribe({ 65 | error: (e) => { 66 | if (synchronous) { 67 | // Can't setState of this component when another one is rendering. 68 | error = e 69 | return 70 | } 71 | setSubscribedSource(() => { 72 | throw e 73 | }) 74 | }, 75 | }), 76 | ) 77 | synchronous = false 78 | if (error !== EMPTY_VALUE) { 79 | throw error 80 | } 81 | }, 82 | } 83 | } 84 | 85 | const [subscribedSource, setSubscribedSource] = useState< 86 | Observable | null | undefined 87 | >(null) 88 | 89 | if (subscribedSource !== null && subscribedSource !== source$) { 90 | if (source$ === undefined) { 91 | setSubscribedSource(source$) 92 | } else { 93 | try { 94 | ;(source$ as any).getValue() 95 | setSubscribedSource(source$) 96 | } catch (e: any) {} 97 | } 98 | } 99 | 100 | useEffect(() => { 101 | setSubscribedSource(source$) 102 | if (!source$) return 103 | 104 | const subscription = liftSuspense()(source$).subscribe({ 105 | error: (e) => 106 | setSubscribedSource(() => { 107 | throw e 108 | }), 109 | }) 110 | return () => { 111 | subscription.unsubscribe() 112 | } 113 | }, [source$]) 114 | 115 | useEffect(() => { 116 | return () => { 117 | subscriptionRef.current?.s.unsubscribe() 118 | subscriptionRef.current = undefined 119 | } 120 | }, []) 121 | 122 | const actualChildren = 123 | subscribedSource === source$ ? ( 124 | {children} 125 | ) : fallback === undefined ? null : ( 126 | 127 | ) 128 | 129 | return fallback === undefined ? ( 130 | actualChildren 131 | ) : subscribedSource === null ? ( 132 | fallback 133 | ) : ( 134 | {actualChildren} 135 | ) 136 | } 137 | 138 | /** 139 | * Component that prevents its children from using the parent `Subscribe` boundary 140 | * to manage their subscriptions. 141 | */ 142 | export const RemoveSubscribe: React.FC<{ 143 | children?: React.ReactNode | undefined 144 | }> = ({ children }) => {children} 145 | -------------------------------------------------------------------------------- /packages/core/src/bind/connectFactoryObservable.test.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | from, 3 | of, 4 | defer, 5 | concat, 6 | throwError, 7 | Observable, 8 | Subject, 9 | merge, 10 | EMPTY, 11 | NEVER, 12 | } from "rxjs" 13 | import { renderHook, act as actHook } from "@testing-library/react" 14 | import { 15 | delay, 16 | take, 17 | catchError, 18 | map, 19 | switchMapTo, 20 | first, 21 | startWith, 22 | switchMap, 23 | } from "rxjs/operators" 24 | import { FC, useState } from "react" 25 | import React from "react" 26 | import { 27 | act as componentAct, 28 | fireEvent, 29 | screen, 30 | render, 31 | act, 32 | waitFor, 33 | } from "@testing-library/react" 34 | import { describe, it, beforeAll, afterAll, expect, vi } from "vitest" 35 | import { bind, Subscribe } from "../" 36 | import { TestErrorBoundary } from "../test-helpers/TestErrorBoundary" 37 | 38 | const wait = (ms: number) => new Promise((res) => setTimeout(res, ms)) 39 | 40 | describe("connectFactoryObservable", () => { 41 | const originalError = console.error 42 | beforeAll(() => { 43 | console.error = (...args: any) => { 44 | if ( 45 | /Uncaught 'controlled error'/.test(args[0]) || 46 | /using the error boundary .* TestErrorBoundary/.test(args[0]) 47 | ) { 48 | return 49 | } 50 | originalError.call(console, ...args) 51 | } 52 | }) 53 | 54 | afterAll(() => { 55 | console.error = originalError 56 | }) 57 | describe("hook", () => { 58 | it("returns the latest emitted value", async () => { 59 | const valueStream = new Subject() 60 | const [useNumber] = bind(() => valueStream, 1) 61 | const { result } = renderHook(() => useNumber()) 62 | expect(result.current).toBe(1) 63 | 64 | actHook(() => { 65 | valueStream.next(3) 66 | }) 67 | expect(result.current).toBe(3) 68 | }) 69 | 70 | it("suspends the component when the observable hasn't emitted yet.", async () => { 71 | const source$ = of(1).pipe(delay(100)) 72 | const [useDelayedNumber, getDelayedNumber$] = bind(() => source$) 73 | const Result: React.FC = () =>
Result {useDelayedNumber()}
74 | const TestSuspense: React.FC = () => { 75 | return ( 76 | Waiting} 79 | > 80 | 81 | 82 | ) 83 | } 84 | 85 | render() 86 | 87 | expect(screen.queryByText("Result")).toBeNull() 88 | expect(screen.queryByText("Waiting")).not.toBeNull() 89 | 90 | await wait(110) 91 | 92 | vi.waitFor( 93 | () => { 94 | expect(screen.queryByText("Result 1")).not.toBeNull() 95 | expect(screen.queryByText("Waiting")).toBeNull() 96 | }, 97 | { timeout: 2000 }, 98 | ) 99 | }) 100 | 101 | it("synchronously mounts the emitted value if the observable emits synchronously", () => { 102 | const source$ = of(1) 103 | const [useDelayedNumber, getDelayedNumber$] = bind(() => source$) 104 | const Result: React.FC = () =>
Result {useDelayedNumber()}
105 | const TestSuspense: React.FC = () => { 106 | return ( 107 | Waiting} 110 | > 111 | 112 | 113 | ) 114 | } 115 | 116 | render() 117 | 118 | expect(screen.queryByText("Result 1")).not.toBeNull() 119 | expect(screen.queryByText("Waiting")).toBeNull() 120 | }) 121 | 122 | it("doesn't mount the fallback element if the subscription is already active", () => { 123 | const source$ = new Subject() 124 | const [useDelayedNumber, getDelayedNumber$] = bind(() => source$) 125 | const Result: React.FC = () =>
Result {useDelayedNumber()}
126 | const TestSuspense: React.FC = () => { 127 | return ( 128 | Waiting} 131 | > 132 | 133 | 134 | ) 135 | } 136 | 137 | const subscription = getDelayedNumber$().subscribe() 138 | source$.next(1) 139 | render() 140 | 141 | expect(screen.queryByText("Result 1")).not.toBeNull() 142 | expect(screen.queryByText("Waiting")).toBeNull() 143 | subscription.unsubscribe() 144 | }) 145 | 146 | it("shares the multicasted subscription with all of the components that use the same parameters", async () => { 147 | let subscriberCount = 0 148 | const observable$ = defer(() => { 149 | subscriberCount += 1 150 | return from([1, 2, 3, 4, 5]) 151 | }) 152 | 153 | const [useLatestNumber, latestNumber$] = bind( 154 | (id: number, value: { val: number }) => 155 | concat(observable$, of(id + value.val)), 156 | ) 157 | expect(subscriberCount).toBe(0) 158 | 159 | const first = { val: 1 } 160 | latestNumber$(1, first).subscribe() 161 | renderHook(() => useLatestNumber(1, first)) 162 | expect(subscriberCount).toBe(1) 163 | 164 | renderHook(() => useLatestNumber(1, first)) 165 | expect(subscriberCount).toBe(1) 166 | 167 | expect(subscriberCount).toBe(1) 168 | 169 | const second = { val: 2 } 170 | latestNumber$(1, second).subscribe() 171 | renderHook(() => useLatestNumber(1, second)) 172 | expect(subscriberCount).toBe(2) 173 | 174 | latestNumber$(2, second).subscribe() 175 | renderHook(() => useLatestNumber(2, second)) 176 | expect(subscriberCount).toBe(3) 177 | }) 178 | 179 | it("returns the value of next new Observable when the arguments change", () => { 180 | const [useNumber, getNumber$] = bind((x: number) => of(x)) 181 | const subs = merge( 182 | getNumber$(0), 183 | getNumber$(1), 184 | getNumber$(2), 185 | ).subscribe() 186 | const { result, rerender } = renderHook(({ input }) => useNumber(input), { 187 | initialProps: { input: 0 }, 188 | }) 189 | expect(result.current).toBe(0) 190 | 191 | actHook(() => { 192 | rerender({ input: 1 }) 193 | }) 194 | expect(result.current).toBe(1) 195 | 196 | actHook(() => { 197 | rerender({ input: 2 }) 198 | }) 199 | expect(result.current).toBe(2) 200 | subs.unsubscribe() 201 | }) 202 | 203 | it("immediately switches the state to the new observable", () => { 204 | const [useNumber, getNumber$] = bind((x: number) => of(x)) 205 | merge(getNumber$(0), getNumber$(1), getNumber$(2)).subscribe() 206 | 207 | const Form = ({ id }: { id: number }) => { 208 | const value = useNumber(id) 209 | 210 | return 211 | } 212 | 213 | const { rerender, getByRole } = render(
) 214 | expect((getByRole("input") as HTMLInputElement).value).toBe("0") 215 | 216 | act(() => rerender()) 217 | expect((getByRole("input") as HTMLInputElement).value).toBe("1") 218 | 219 | act(() => rerender()) 220 | expect((getByRole("input") as HTMLInputElement).value).toBe("2") 221 | }) 222 | 223 | it("handles optional args correctly", () => { 224 | const [, getNumber$] = bind((x: number, y?: number) => of(x + (y ?? 0))) 225 | 226 | expect(getNumber$(5)).toBe(getNumber$(5, undefined)) 227 | expect(getNumber$(6, undefined)).toBe(getNumber$(6)) 228 | }) 229 | 230 | it("suspends the component when the factory-observable hasn't emitted yet.", async () => { 231 | const [useDelayedNumber, getDelayedNumber$] = bind((x: number) => 232 | of(x).pipe(delay(50)), 233 | ) 234 | const Result: React.FC<{ input: number }> = (p) => ( 235 |
Result {useDelayedNumber(p.input)}
236 | ) 237 | const TestSuspense: React.FC = () => { 238 | const [input, setInput] = useState(0) 239 | return ( 240 | <> 241 | Waiting} 244 | > 245 | 246 | 247 | 248 | 249 | ) 250 | } 251 | 252 | getDelayedNumber$(0).subscribe() 253 | render() 254 | expect(screen.queryByText("Result")).toBeNull() 255 | expect(screen.queryByText("Waiting")).not.toBeNull() 256 | await componentAct(async () => { 257 | await getDelayedNumber$(0).pipe(first()).toPromise() 258 | await wait(0) 259 | }) 260 | expect(screen.queryByText("Result 0")).not.toBeNull() 261 | expect(screen.queryByText("Waiting")).toBeNull() 262 | 263 | componentAct(() => { 264 | getDelayedNumber$(1).subscribe() 265 | fireEvent.click(screen.getByText(/increase/i)) 266 | }) 267 | expect(screen.queryByText("Result")).toBeNull() 268 | expect(screen.queryByText("Waiting")).not.toBeNull() 269 | await componentAct(async () => { 270 | await wait(60) 271 | }) 272 | expect(screen.queryByText("Result 1")).not.toBeNull() 273 | expect(screen.queryByText("Waiting")).toBeNull() 274 | 275 | componentAct(() => { 276 | getDelayedNumber$(2).subscribe() 277 | fireEvent.click(screen.getByText(/increase/i)) 278 | }) 279 | expect(screen.queryByText("Result")).toBeNull() 280 | expect(screen.queryByText("Waiting")).not.toBeNull() 281 | await componentAct(async () => { 282 | await wait(60) 283 | }) 284 | expect(screen.queryByText("Result 2")).not.toBeNull() 285 | expect(screen.queryByText("Waiting")).toBeNull() 286 | }) 287 | 288 | it("shares the source subscription until the refCount has stayed at zero for the grace-period", async () => { 289 | let nInitCount = 0 290 | const observable$ = defer(() => { 291 | nInitCount += 1 292 | return from([1, 2, 3, 4, 5]) 293 | }) 294 | 295 | const [useLatestNumber, getLatestNumber$] = bind((id: number) => 296 | concat(observable$, of(id)), 297 | ) 298 | let subs = getLatestNumber$(6).subscribe() 299 | const { unmount } = renderHook(() => useLatestNumber(6)) 300 | const { unmount: unmount2 } = renderHook(() => useLatestNumber(6)) 301 | const { unmount: unmount3 } = renderHook(() => useLatestNumber(6)) 302 | expect(nInitCount).toBe(1) 303 | unmount() 304 | unmount2() 305 | unmount3() 306 | 307 | const { unmount: unmount4 } = renderHook(() => useLatestNumber(6)) 308 | expect(nInitCount).toBe(1) 309 | 310 | unmount4() 311 | subs.unsubscribe() 312 | 313 | getLatestNumber$(6).subscribe() 314 | renderHook(() => useLatestNumber(6)) 315 | expect(nInitCount).toBe(2) 316 | }) 317 | 318 | it("allows errors to be caught in error boundaries", () => { 319 | const errStream = new Subject() 320 | const [useError] = bind(() => errStream, 1) 321 | 322 | const ErrorComponent = () => { 323 | const value = useError() 324 | 325 | return <>{value} 326 | } 327 | 328 | const errorCallback = vi.fn() 329 | render( 330 | 331 | 332 | , 333 | ) 334 | 335 | componentAct(() => { 336 | errStream.error("controlled error") 337 | }) 338 | 339 | expect(errorCallback).toHaveBeenCalledWith( 340 | "controlled error", 341 | expect.any(Object), 342 | ) 343 | }) 344 | 345 | it("allows sync errors to be caught in error boundaries with suspense", () => { 346 | const errStream = new Observable((observer) => 347 | observer.error("controlled error"), 348 | ) 349 | const [useError, getErrStream$] = bind((_: string) => errStream) 350 | 351 | const ErrorComponent = () => { 352 | const value = useError("foo") 353 | 354 | return <>{value} 355 | } 356 | 357 | const errorCallback = vi.fn() 358 | const { unmount } = render( 359 | 360 | Loading...} 363 | > 364 | 365 | 366 | , 367 | ) 368 | 369 | expect(errorCallback).toHaveBeenCalledWith( 370 | "controlled error", 371 | expect.any(Object), 372 | ) 373 | unmount() 374 | }) 375 | 376 | it("allows async errors to be caught in error boundaries with suspense", async () => { 377 | const errStream = new Subject() 378 | const [useError, getErrStream$] = bind((_: string) => errStream) 379 | 380 | const ErrorComponent = () => { 381 | const value = useError("foo") 382 | 383 | return <>{value} 384 | } 385 | 386 | const errorCallback = vi.fn() 387 | const { unmount } = render( 388 | 389 | Loading...} 392 | > 393 | 394 | 395 | , 396 | ) 397 | 398 | await componentAct(async () => { 399 | errStream.error("controlled error") 400 | await wait(10) 401 | }) 402 | 403 | expect(errorCallback).toHaveBeenCalledWith( 404 | "controlled error", 405 | expect.any(Object), 406 | ) 407 | unmount() 408 | }) 409 | 410 | it( 411 | "the errror-boundary can capture errors that are produced when changing the " + 412 | "key of the hook to an observable that throws synchronously", 413 | async () => { 414 | const normal$ = new Subject() 415 | const errored$ = new Observable((observer) => { 416 | observer.error("controlled error") 417 | }) 418 | 419 | const [useOkKo, getObs$] = bind((ok: boolean) => 420 | ok ? normal$ : errored$, 421 | ) 422 | getObs$(true).subscribe() 423 | getObs$(false) 424 | .pipe(catchError(() => [])) 425 | .subscribe() 426 | 427 | const Ok: React.FC<{ ok: boolean }> = ({ ok }) => <>{useOkKo(ok)} 428 | 429 | const ErrorComponent = () => { 430 | const [ok, setOk] = useState(true) 431 | 432 | return ( 433 | Loading...}> 434 | setOk(false)}> 435 | 436 | 437 | 438 | ) 439 | } 440 | 441 | const errorCallback = vi.fn() 442 | const { unmount } = render( 443 | 444 | 445 | , 446 | ) 447 | 448 | expect(screen.queryByText("ALL GOOD")).toBeNull() 449 | expect(screen.queryByText("Loading...")).not.toBeNull() 450 | 451 | await componentAct(async () => { 452 | normal$.next("ALL GOOD") 453 | await wait(50) 454 | }) 455 | 456 | expect(screen.queryByText("ALL GOOD")).not.toBeNull() 457 | expect(screen.queryByText("Loading...")).toBeNull() 458 | expect(errorCallback).not.toHaveBeenCalled() 459 | 460 | componentAct(() => { 461 | fireEvent.click(screen.getByText(/GOOD/i)) 462 | }) 463 | 464 | expect(errorCallback).toHaveBeenCalledWith( 465 | "controlled error", 466 | expect.any(Object), 467 | ) 468 | 469 | unmount() 470 | }, 471 | ) 472 | 473 | it("doesn't throw errors on components that will get unmounted on the next cycle", () => { 474 | const valueStream = new Subject() 475 | const [useValue] = bind(() => valueStream, 1) 476 | const [useError] = bind( 477 | () => valueStream.pipe(switchMapTo(throwError("error"))), 478 | 1, 479 | ) 480 | 481 | const ErrorComponent: FC = () => { 482 | const value = useError() 483 | 484 | return <>{value} 485 | } 486 | 487 | const Container: FC = () => { 488 | const value = useValue() 489 | 490 | return value === 1 ? : <>Nothing to show here 491 | } 492 | 493 | const errorCallback = vi.fn() 494 | render( 495 | 496 | 497 | , 498 | ) 499 | 500 | componentAct(() => { 501 | valueStream.next(2) 502 | }) 503 | 504 | expect(errorCallback).not.toHaveBeenCalled() 505 | }) 506 | 507 | it("supports streams that emit functions", () => { 508 | const values$ = new Subject() 509 | 510 | const [useFunction, function$] = bind(() => 511 | values$.pipe( 512 | startWith(0), 513 | map((value) => () => value), 514 | ), 515 | ) 516 | const subscription = function$().subscribe() 517 | 518 | const { result } = renderHook(() => useFunction()) 519 | 520 | expect(result.current()).toBe(0) 521 | 522 | actHook(() => { 523 | values$.next(1) 524 | }) 525 | 526 | expect(result.current()).toBe(1) 527 | 528 | subscription.unsubscribe() 529 | }) 530 | 531 | it("the defaultValue can be undefined", () => { 532 | const number$ = new Subject() 533 | const [useNumber] = bind(() => number$, undefined) 534 | 535 | const { result, unmount } = renderHook(() => useNumber()) 536 | 537 | expect(result.current).toBe(undefined) 538 | 539 | actHook(() => { 540 | number$.next(5) 541 | }) 542 | 543 | expect(result.current).toBe(5) 544 | 545 | unmount() 546 | }) 547 | 548 | it("the defaultValue can be a function that receives the keys", () => { 549 | const subj$ = new Subject() 550 | const [useNumber, number$] = bind( 551 | (_: number) => subj$, 552 | (key) => key, 553 | ) 554 | 555 | const { result, unmount } = renderHook(() => useNumber(10)) 556 | 557 | expect(result.current).toBe(10) 558 | let res = 0 559 | number$(10) 560 | .subscribe((x) => { 561 | res = x 562 | }) 563 | .unsubscribe() 564 | expect(res).toBe(10) 565 | 566 | actHook(() => { 567 | subj$.next(5) 568 | }) 569 | 570 | expect(result.current).toBe(5) 571 | 572 | unmount() 573 | }) 574 | 575 | it("if the observable hasn't emitted and a defaultValue is provided, it does not start suspense", () => { 576 | const number$ = new Subject() 577 | const [useNumber] = bind( 578 | (id: number) => number$.pipe(map((x) => x + id)), 579 | 0, 580 | ) 581 | 582 | const { result, unmount } = renderHook(() => useNumber(5)) 583 | 584 | expect(result.current).toBe(0) 585 | 586 | actHook(() => { 587 | number$.next(5) 588 | }) 589 | 590 | expect(result.current).toBe(10) 591 | 592 | unmount() 593 | }) 594 | 595 | it("when a defaultValue is provided, the first subscription happens once the component is mounted", () => { 596 | let nTopSubscriptions = 0 597 | 598 | const [useNTopSubscriptions] = bind( 599 | (id: number) => 600 | defer(() => { 601 | return of(++nTopSubscriptions + id) 602 | }), 603 | 1, 604 | ) 605 | 606 | const { result, rerender, unmount } = renderHook(() => 607 | useNTopSubscriptions(0), 608 | ) 609 | 610 | expect(result.current).toBe(1) 611 | 612 | actHook(() => { 613 | rerender() 614 | }) 615 | 616 | expect(result.current).toBe(1) 617 | expect(nTopSubscriptions).toBe(1) 618 | 619 | unmount() 620 | }) 621 | }) 622 | 623 | it("when a defaultValue is provided, the resulting observable should emmit the defaultValue first if the source doesn't synchronously emmit anything", () => { 624 | let value = 0 625 | let [, result$] = bind<[], number>(() => NEVER, 10) 626 | result$().subscribe((v) => { 627 | value = v 628 | }) 629 | expect(value).toBe(10) 630 | 631 | value = 0 632 | ;[, result$] = bind(() => EMPTY, 10) 633 | result$().subscribe((v) => { 634 | value = v 635 | }) 636 | expect(value).toBe(10) 637 | 638 | value = 0 639 | ;[, result$] = bind(() => of(5), 10) 640 | result$().subscribe((v) => { 641 | value += v 642 | }) 643 | expect(value).toBe(5) 644 | }) 645 | 646 | it("ensures that components subscriptions are being taken into account", async () => { 647 | const ticks$ = new Subject() 648 | const [useValue, getValue$] = bind((_: string) => 649 | ticks$.pipe(map((_, idx) => idx)), 650 | ) 651 | const update$ = new Subject() 652 | 653 | const key = "foo" 654 | const subscription$ = update$.pipe( 655 | startWith(""), 656 | switchMap(() => getValue$(key)), 657 | ) 658 | 659 | const Result: React.FC = () =>
Result {useValue(key)}
660 | const Container: React.FC = () => { 661 | return ( 662 | <> 663 | Waiting}> 664 | 665 | 666 | 667 | 668 | ) 669 | } 670 | 671 | render() 672 | 673 | expect(screen.queryByText("Waiting")).not.toBeNull() 674 | componentAct(() => { 675 | ticks$.next() 676 | }) 677 | 678 | await waitFor(() => expect(screen.queryByText("Waiting")).toBeNull()) 679 | expect(screen.getByText("Result 0")).not.toBeNull() 680 | 681 | componentAct(() => { 682 | ticks$.next() 683 | }) 684 | 685 | await waitFor(() => expect(screen.getByText("Result 1")).not.toBeNull()) 686 | 687 | componentAct(() => { 688 | ticks$.next() 689 | }) 690 | 691 | await waitFor(() => expect(screen.getByText("Result 2")).not.toBeNull()) 692 | 693 | componentAct(() => { 694 | fireEvent.click(screen.getByText(/Next/i)) 695 | }) 696 | 697 | expect(screen.getByText("Result 2")).not.toBeNull() 698 | 699 | componentAct(() => { 700 | ticks$.next() 701 | }) 702 | 703 | await waitFor(() => expect(screen.getByText("Result 3")).not.toBeNull()) 704 | }) 705 | 706 | describe("observable", () => { 707 | it("it does not complete when the source observable completes", async () => { 708 | let diff = -1 709 | const [useLatestNumber, getShared] = bind((_: number) => { 710 | diff++ 711 | return from([1, 2, 3, 4].map((val) => val + diff)) 712 | }) 713 | 714 | let latestValue1: number = 0 715 | let nUpdates = 0 716 | const sub1 = getShared(0).subscribe((x) => { 717 | latestValue1 = x 718 | nUpdates += 1 719 | }) 720 | expect(latestValue1).toBe(4) 721 | expect(nUpdates).toBe(4) 722 | expect(sub1.closed).toBe(false) 723 | sub1.unsubscribe() 724 | 725 | let sub = getShared(0).subscribe() 726 | const { result, unmount } = renderHook(() => useLatestNumber(0)) 727 | expect(result.current).toBe(5) 728 | expect(nUpdates).toBe(4) 729 | 730 | let latestValue2: number = 0 731 | const sub2 = getShared(0).subscribe((x) => { 732 | latestValue2 = x 733 | nUpdates += 1 734 | }) 735 | expect(latestValue2).toBe(5) 736 | expect(nUpdates).toBe(5) 737 | expect(sub2.closed).toBe(false) 738 | sub2.unsubscribe() 739 | 740 | let latestValue3: number = 0 741 | const sub3 = getShared(0).subscribe((x) => { 742 | latestValue3 = x 743 | nUpdates += 1 744 | }) 745 | expect(latestValue3).toBe(5) 746 | expect(nUpdates).toBe(6) 747 | expect(sub3.closed).toBe(false) 748 | sub3.unsubscribe() 749 | 750 | unmount() 751 | sub.unsubscribe() 752 | 753 | let latestValue4: number = 0 754 | const sub4 = getShared(0).subscribe((x) => { 755 | latestValue4 = x 756 | nUpdates += 1 757 | }) 758 | expect(latestValue4).toBe(6) 759 | expect(nUpdates).toBe(10) 760 | expect(sub4.closed).toBe(false) 761 | sub4.unsubscribe() 762 | }) 763 | 764 | describe("re-subscriptions on disposed observables", () => { 765 | it("registers itself when no other observable has been registered for that key", () => { 766 | const key = 0 767 | let sideEffects = 0 768 | 769 | const [, getShared] = bind((_: number) => 770 | defer(() => { 771 | return of(++sideEffects) 772 | }), 773 | ) 774 | 775 | const stream = getShared(key) 776 | 777 | let val 778 | stream.pipe(take(1)).subscribe((x) => { 779 | val = x 780 | }) 781 | expect(val).toBe(1) 782 | 783 | stream.pipe(take(1)).subscribe((x) => { 784 | val = x 785 | }) 786 | expect(val).toBe(2) 787 | 788 | const subscription = stream.subscribe((x) => { 789 | val = x 790 | }) 791 | expect(val).toBe(3) 792 | 793 | getShared(key) 794 | .pipe(take(1)) 795 | .subscribe((x) => { 796 | val = x 797 | }) 798 | expect(val).toBe(3) 799 | subscription.unsubscribe() 800 | }) 801 | 802 | it("subscribes to the currently registered observable if a new observalbe has been registered for that key", () => { 803 | const key = 0 804 | let sideEffects = 0 805 | 806 | const [, getShared] = bind((_: number) => 807 | defer(() => { 808 | return of(++sideEffects) 809 | }), 810 | ) 811 | 812 | const stream = getShared(key) 813 | 814 | let val 815 | stream.pipe(take(1)).subscribe((x) => { 816 | val = x 817 | }) 818 | expect(val).toBe(1) 819 | 820 | const subscription = getShared(key).subscribe((x) => { 821 | val = x 822 | }) 823 | expect(val).toBe(2) 824 | 825 | stream.pipe(take(1)).subscribe((x) => { 826 | val = x 827 | }) 828 | expect(val).toBe(2) 829 | 830 | stream.pipe(take(1)).subscribe((x) => { 831 | val = x 832 | }) 833 | expect(val).toBe(2) 834 | 835 | subscription.unsubscribe() 836 | }) 837 | 838 | it("does not crash when the observable lazily references its enhanced self", () => { 839 | const [, obs$] = bind( 840 | (key: number) => defer(() => obs$(key)).pipe(take(1)), 841 | (key: number) => key, 842 | ) as [(key: number) => number, (key: number) => Observable] 843 | 844 | let error = null 845 | obs$(1) 846 | .subscribe({ 847 | error: (e: any) => { 848 | error = e 849 | }, 850 | }) 851 | .unsubscribe() 852 | 853 | expect(error).toBeNull() 854 | }) 855 | 856 | it("does not crash when the factory function self-references its enhanced self", () => { 857 | let nSubscriptions = 0 858 | const [, me$] = bind( 859 | (key: number): Observable => { 860 | nSubscriptions++ 861 | return defer(() => me$(key)).pipe( 862 | take(1), 863 | map((x) => x * 2), 864 | ) 865 | }, 866 | (key: number) => key, 867 | ) 868 | 869 | let value = 0 870 | const sub1 = me$(5).subscribe((val) => { 871 | value = val 872 | }) 873 | 874 | expect(value).toBe(10) 875 | expect(sub1.closed).toBe(false) 876 | 877 | value = 0 878 | const sub2 = me$(5).subscribe((val) => { 879 | value = val 880 | }) 881 | 882 | expect(value).toBe(10) 883 | expect(nSubscriptions).toBe(1) 884 | 885 | sub1.unsubscribe() 886 | sub2.unsubscribe() 887 | 888 | const sub3 = me$(5).subscribe((val) => { 889 | value = val 890 | }) 891 | 892 | expect(value).toBe(10) 893 | expect(nSubscriptions).toBe(2) 894 | sub3.unsubscribe() 895 | }) 896 | }) 897 | }) 898 | }) 899 | -------------------------------------------------------------------------------- /packages/core/src/bind/connectFactoryObservable.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from "rxjs" 2 | import { EMPTY_VALUE } from "../internal/empty-value" 3 | import { state, StateObservable, SUSPENSE } from "@rx-state/core" 4 | import { useStateObservable } from "../" 5 | 6 | /** 7 | * Accepts: A factory function that returns an Observable. 8 | * 9 | * Returns [1, 2] 10 | * 1. A React Hook function with the same parameters as the factory function. 11 | * This hook will yield the latest update from the observable returned from 12 | * the factory function. 13 | * 2. A `sharedLatest` version of the observable generated by the factory 14 | * function that can be used for composing other streams that depend on it. 15 | * The shared subscription is closed as soon as there are no subscribers to 16 | * that observable. 17 | * 18 | * @param getObservable Factory of observables. The arguments of this function 19 | * will be the ones used in the hook. 20 | * 21 | * @remarks If the Observable doesn't synchronously emit a value upon the first 22 | * subscription, then the hook will leverage React Suspense while it's waiting 23 | * for the first value. 24 | */ 25 | export default function connectFactoryObservable( 26 | getObservable: (...args: A) => Observable, 27 | defaultValue: O | ((...args: A) => O), 28 | ): [(...args: A) => Exclude, (...args: A) => StateObservable] { 29 | const args: 30 | | [(...args: A) => Observable] 31 | | [(...args: A) => Observable, O | ((...args: A) => O)] = 32 | defaultValue === EMPTY_VALUE 33 | ? [getObservable] 34 | : [getObservable, defaultValue] 35 | 36 | const obs = state(...(args as [(...args: A) => Observable])) 37 | return [ 38 | (...input: A) => useStateObservable(obs(...(input as any))), 39 | obs as any, 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /packages/core/src/bind/connectObservable.ts: -------------------------------------------------------------------------------- 1 | import { EMPTY_VALUE } from "../internal/empty-value" 2 | import { Observable } from "rxjs" 3 | import { useStateObservable } from "../" 4 | import { state } from "@rx-state/core" 5 | 6 | /** 7 | * Accepts: An Observable. 8 | * 9 | * Returns [1, 2] 10 | * 1. A React Hook that yields the latest emitted value of the observable 11 | * 2. A `sharedLatest` version of the observable. It can be used for composing 12 | * other streams that depend on it. The shared subscription is closed as soon as 13 | * there are no subscribers to that observable. 14 | * 15 | * @param observable Source observable to be used by the hook. 16 | * 17 | * @remarks If the Observable doesn't synchronously emit a value upon the first 18 | * subscription, then the hook will leverage React Suspense while it's waiting 19 | * for the first value. 20 | */ 21 | export default function connectObservable( 22 | observable: Observable, 23 | defaultValue: T, 24 | ) { 25 | const sharedObservable$ = 26 | defaultValue === EMPTY_VALUE 27 | ? state(observable) 28 | : state(observable, defaultValue) 29 | 30 | const useStaticObservable = () => useStateObservable(sharedObservable$ as any) 31 | return [useStaticObservable, sharedObservable$] as const 32 | } 33 | -------------------------------------------------------------------------------- /packages/core/src/bind/index.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from "rxjs" 2 | import connectFactoryObservable from "./connectFactoryObservable" 3 | import connectObservable from "./connectObservable" 4 | import { EMPTY_VALUE } from "../internal/empty-value" 5 | import { 6 | StateObservable, 7 | DefaultedStateObservable, 8 | SUSPENSE, 9 | } from "@rx-state/core" 10 | 11 | // Adds an additional "stop" argument to prevent using factory functions 12 | // inside high-order-functions directly (e.g. switchMap(factory$)) 13 | type AddStopArg> = number extends A["length"] 14 | ? A 15 | : [...args: A, _stop?: undefined] 16 | 17 | /** 18 | * Binds an observable to React 19 | * 20 | * @param {Observable} observable - Source observable to be used by the hook. 21 | * @returns [1, 2] 22 | * 1. A React Hook that yields the latest emitted value of the observable 23 | * 2. A `sharedLatest` version of the observable. It can be used for composing 24 | * other streams that depend on it. The shared subscription is closed as soon as 25 | * there are no subscribers to that observable. 26 | * 27 | * @remarks If the Observable doesn't synchronously emit a value upon the first 28 | * subscription, then the hook will leverage React Suspense while it's waiting 29 | * for the first value. 30 | */ 31 | export function bind( 32 | observable: Observable, 33 | ): [() => Exclude, StateObservable] 34 | 35 | /** 36 | * Binds an observable to React 37 | * 38 | * @param {Observable} observable - Source observable to be used by the hook. 39 | * @param {T} defaultValue - Default value that will be used if the observable 40 | * has not emitted any values. 41 | * @returns [1, 2] 42 | * 1. A React Hook that yields the latest emitted value of the observable 43 | * 2. A `sharedLatest` version of the observable. It can be used for composing 44 | * other streams that depend on it. The shared subscription is closed as soon as 45 | * there are no subscribers to that observable. 46 | */ 47 | export function bind( 48 | observable: Observable, 49 | defaultValue: T, 50 | ): [() => Exclude, DefaultedStateObservable] 51 | 52 | /** 53 | * Binds a factory observable to React 54 | * 55 | * @param {(...args: any) => Observable} getObservable - Factory of observables. The arguments of this function 56 | * will be the ones used in the hook. 57 | * @returns [1, 2] 58 | * 1. A React Hook function with the same parameters as the factory function. 59 | * This hook will yield the latest update from the observable returned from 60 | * the factory function. 61 | * 2. A `sharedLatest` version of the observable generated by the factory 62 | * function that can be used for composing other streams that depend on it. 63 | * The shared subscription is closed as soon as there are no subscribers to 64 | * that observable. 65 | * 66 | * @remarks If the Observable doesn't synchronously emit a value upon the first 67 | * subscription, then the hook will leverage React Suspense while it's waiting 68 | * for the first value. 69 | */ 70 | export function bind( 71 | getObservable: (...args: A) => Observable, 72 | ): [ 73 | (...args: AddStopArg) => Exclude, 74 | (...args: AddStopArg) => StateObservable, 75 | ] 76 | 77 | /** 78 | * Binds a factory observable to React 79 | * 80 | * @param {(...args: any) => Observable} getObservable - Factory of observables. The arguments of this function 81 | * will be the ones used in the hook. 82 | * @param {T} defaultValue - Function or value that will be used of the observable 83 | * has not emitted. 84 | * @returns [1, 2] 85 | * 1. A React Hook function with the same parameters as the factory function. 86 | * This hook will yield the latest update from the observable returned from 87 | * the factory function. 88 | * 2. A `sharedLatest` version of the observable generated by the factory 89 | * function that can be used for composing other streams that depend on it. 90 | * The shared subscription is closed as soon as there are no subscribers to 91 | * that observable. 92 | */ 93 | export function bind( 94 | getObservable: (...args: A) => Observable, 95 | defaultValue: O | ((...args: A) => O), 96 | ): [ 97 | (...args: AddStopArg) => Exclude, 98 | (...args: AddStopArg) => DefaultedStateObservable, 99 | ] 100 | 101 | export function bind(observable: any, defaultValue?: any) { 102 | return ( 103 | typeof observable === "function" 104 | ? (connectFactoryObservable as any) 105 | : connectObservable 106 | )(observable, arguments.length > 1 ? defaultValue : EMPTY_VALUE) 107 | } 108 | -------------------------------------------------------------------------------- /packages/core/src/index.tsx: -------------------------------------------------------------------------------- 1 | export type { 2 | AddStopArg, 3 | DefaultedStateObservable, 4 | EmptyObservableError, 5 | NoSubscribersError, 6 | PipeState, 7 | StateObservable, 8 | StatePromise, 9 | WithDefaultOperator, 10 | } from "@rx-state/core" 11 | export { 12 | liftSuspense, 13 | sinkSuspense, 14 | SUSPENSE, 15 | withDefault, 16 | } from "@rx-state/core" 17 | export { bind } from "./bind" 18 | export { shareLatest } from "./shareLatest" 19 | export { state } from "./stateJsx" 20 | export { RemoveSubscribe, Subscribe } from "./Subscribe" 21 | export { useStateObservable } from "./useStateObservable" 22 | -------------------------------------------------------------------------------- /packages/core/src/internal/empty-value.ts: -------------------------------------------------------------------------------- 1 | export const EMPTY_VALUE: any = {} 2 | -------------------------------------------------------------------------------- /packages/core/src/internal/useSyncExternalStore.ts: -------------------------------------------------------------------------------- 1 | export { useSyncExternalStore as default } from "use-sync-external-store/shim/index.js" 2 | -------------------------------------------------------------------------------- /packages/core/src/internal/useSyncExternalStoreCjs.ts: -------------------------------------------------------------------------------- 1 | export { useSyncExternalStore as default } from "use-sync-external-store/shim" 2 | -------------------------------------------------------------------------------- /packages/core/src/shareLatest.test.ts: -------------------------------------------------------------------------------- 1 | import { TestScheduler } from "rxjs/testing" 2 | import { from, merge, defer, Observable, noop } from "rxjs" 3 | import { shareLatest } from "./" 4 | import { withLatestFrom, startWith, map, take } from "rxjs/operators" 5 | import { describe, it, expect } from "vitest" 6 | 7 | const scheduler = () => 8 | new TestScheduler((actual, expected) => { 9 | expect(actual).toEqual(expected) 10 | }) 11 | 12 | describe("shareLatest", () => { 13 | describe("public shareLatest", () => { 14 | // prettier-ignore 15 | it("should restart due to unsubscriptions", () => { 16 | scheduler().run(({ expectObservable, expectSubscriptions, cold }) => { 17 | const sourceSubs = [] 18 | const source = cold("a-b-c-d-e-f-g-h-i-j") 19 | sourceSubs.push(" ^------!----------------------") 20 | sourceSubs.push(" -----------^------------------") 21 | const sub1 = " ^------!" 22 | const expected1 = " a-b-c-d-" 23 | const sub2 = " -----------^------------------" 24 | const expected2 = " -----------a-b-c-d-e-f-g-h-i-j" 25 | 26 | const shared = shareLatest()(source) 27 | 28 | expectObservable(shared, sub1).toBe(expected1) 29 | expectObservable(shared, sub2).toBe(expected2) 30 | expectSubscriptions(source.subscriptions).toBe(sourceSubs) 31 | }) 32 | }) 33 | 34 | // prettier-ignore 35 | it("should restart due to unsubscriptions when the source has completed", () => { 36 | scheduler().run(({ expectObservable, expectSubscriptions, cold }) => { 37 | const sourceSubs = [] 38 | const source = cold('a-(b|) '); 39 | sourceSubs.push( '-^-! '); 40 | sourceSubs.push( '-----------^-!'); 41 | const sub1 = '-^--! '; 42 | const expected1 = '-a-(b|) '; 43 | const sub2 = '-----------^--!'; 44 | const expected2 = '-----------a-(b|)'; 45 | 46 | const shared = shareLatest()(source) 47 | 48 | expectObservable(shared, sub1).toBe(expected1); 49 | expectObservable(shared, sub2).toBe(expected2); 50 | expectSubscriptions(source.subscriptions).toBe(sourceSubs); 51 | }) 52 | }) 53 | 54 | // prettier-ignore 55 | it("should be able to handle recursively synchronous subscriptions", () => { 56 | scheduler().run(({ expectObservable, hot }) => { 57 | const values$ = hot('----b-c-d---') 58 | const latest$ = hot('----------x-') 59 | const expected = ' a---b-c-d-d-' 60 | const input$: any = merge( 61 | values$, 62 | latest$.pipe( 63 | withLatestFrom(defer(() => result$)), 64 | map(([, latest]) => latest) 65 | ) 66 | ) 67 | 68 | const result$: any = input$.pipe( 69 | startWith('a'), 70 | shareLatest() 71 | ) 72 | 73 | expectObservable(result$, '^').toBe(expected) 74 | }) 75 | }) 76 | 77 | // prettier-ignore 78 | it("should not skip values on a sync source", () => { 79 | scheduler().run(({ expectObservable }) => { 80 | const source = from(['a', 'b', 'c', 'd']) // cold("(abcd|)") 81 | const sub1 = '^'; 82 | const expected1 = " (abcd|)" 83 | 84 | const shared = shareLatest()(source); 85 | 86 | expectObservable(shared, sub1).toBe(expected1); 87 | }) 88 | }) 89 | 90 | it("should stop listening to a synchronous observable when unsubscribed", () => { 91 | let sideEffects = 0 92 | const synchronousObservable = new Observable((subscriber) => { 93 | // This will check to see if the subscriber was closed on each loop 94 | // when the unsubscribe hits (from the `take`), it should be closed 95 | for (let i = 0; !subscriber.closed && i < 10; i++) { 96 | sideEffects++ 97 | subscriber.next(i) 98 | } 99 | }) 100 | synchronousObservable.pipe(shareLatest(), take(3)).subscribe(noop) 101 | expect(sideEffects).toBe(3) 102 | }) 103 | }) 104 | }) 105 | -------------------------------------------------------------------------------- /packages/core/src/shareLatest.ts: -------------------------------------------------------------------------------- 1 | import { MonoTypeOperatorFunction, ReplaySubject, share } from "rxjs" 2 | 3 | /** 4 | * A RxJS pipeable operator which shares and replays the latest emitted value. 5 | * It's the equivalent of: 6 | * 7 | * ```ts 8 | * share({ 9 | * connector: () => new ReplaySubject(1), 10 | * resetOnError: true, 11 | * resetOnComplete: true, 12 | * resetOnRefCountZero: true, 13 | * }) 14 | * ``` 15 | */ 16 | export const shareLatest = (): MonoTypeOperatorFunction => 17 | share({ 18 | connector: () => new ReplaySubject(1), 19 | resetOnError: true, 20 | resetOnComplete: true, 21 | resetOnRefCountZero: true, 22 | }) 23 | -------------------------------------------------------------------------------- /packages/core/src/stateJsx.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, act } from "@testing-library/react" 2 | import React, { Suspense } from "react" 3 | import { map, Subject } from "rxjs" 4 | import { describe, it, expect } from "vitest" 5 | import { state } from "./" 6 | 7 | describe("stateJsx", () => { 8 | it("is possible to use StateObservables as JSX elements", async () => { 9 | const subject = new Subject() 10 | const state$ = state(subject) 11 | const subscription = state$.subscribe() 12 | 13 | render({state$}) 14 | 15 | expect(screen.queryByText("Result")).toBeNull() 16 | expect(screen.queryByText("Waiting")).not.toBeNull() 17 | 18 | await act(() => { 19 | subject.next("Result") 20 | return Promise.resolve() 21 | }) 22 | 23 | expect(screen.queryByText("Result")).not.toBeNull() 24 | expect(screen.queryByText("Waiting")).toBeNull() 25 | subscription.unsubscribe() 26 | }) 27 | 28 | it("is possible to use factory StateObservables as JSX elements", async () => { 29 | const subject = new Subject() 30 | const state$ = state((value: string) => subject.pipe(map((x) => value + x))) 31 | 32 | const subscription = state$("hello ").subscribe() 33 | 34 | render({state$("hello ")}) 35 | 36 | expect(screen.queryByText("hello world!")).toBeNull() 37 | expect(screen.queryByText("Waiting")).not.toBeNull() 38 | 39 | await act(() => { 40 | subject.next("world!") 41 | return Promise.resolve() 42 | }) 43 | 44 | expect(screen.queryByText("hello world!")).not.toBeNull() 45 | expect(screen.queryByText("Waiting")).toBeNull() 46 | subscription.unsubscribe() 47 | }) 48 | 49 | it("enhances the result of a pipeState to be used as a JSX element", async () => { 50 | const subject = new Subject() 51 | const state$ = state(subject) 52 | 53 | const derivedState$ = state$.pipeState(map((v) => `derived ${v}`)) 54 | const derivedTwiceState$ = derivedState$.pipeState(map((v) => `${v} again`)) 55 | const subscription = derivedTwiceState$.subscribe() 56 | 57 | render( 58 | 59 | {derivedState$}, {derivedTwiceState$} 60 | , 61 | ) 62 | 63 | expect(screen.queryByText("Waiting")).not.toBeNull() 64 | 65 | await act(() => { 66 | subject.next("Result") 67 | return Promise.resolve() 68 | }) 69 | 70 | expect( 71 | screen.queryByText("derived Result, derived Result again"), 72 | ).not.toBeNull() 73 | expect(screen.queryByText("Waiting")).toBeNull() 74 | subscription.unsubscribe() 75 | }) 76 | }) 77 | -------------------------------------------------------------------------------- /packages/core/src/stateJsx.tsx: -------------------------------------------------------------------------------- 1 | import { state as coreState, StateObservable } from "@rx-state/core" 2 | import React, { createElement, ReactElement } from "react" 3 | import { useStateObservable } from "./useStateObservable" 4 | 5 | declare module "@rx-state/core" { 6 | interface StateObservable extends ReactElement {} 7 | } 8 | 9 | export const state: typeof coreState = (...args: any[]): any => { 10 | const result = (coreState as any)(...args) 11 | 12 | if (typeof result === "function") { 13 | return (...args: any[]) => enhanceState(result(...args)) 14 | } 15 | return enhanceState(result) 16 | } 17 | 18 | const cache = new WeakMap, React.ReactNode>() 19 | function enhanceState(state$: StateObservable) { 20 | if (!cache.has(state$)) 21 | cache.set( 22 | state$, 23 | createElement(() => useStateObservable(state$) as any, {}), 24 | ) 25 | 26 | const originalPipeState = state$.pipeState.bind(state$) 27 | return Object.assign(state$, cache.get(state$)!, { 28 | pipeState: (...operators: any[]) => 29 | enhanceState((originalPipeState as any)(...operators)), 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /packages/core/src/test-helpers/TestErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { Component, ErrorInfo } from "react" 2 | 3 | export class TestErrorBoundary extends Component< 4 | { 5 | children?: React.ReactNode | undefined 6 | onError: (error: Error, errorInfo: ErrorInfo) => void 7 | }, 8 | { 9 | hasError: boolean 10 | } 11 | > { 12 | state = { 13 | hasError: false, 14 | } 15 | 16 | componentDidCatch(error: Error, errorInfo: ErrorInfo) { 17 | this.props.onError(error, errorInfo) 18 | } 19 | 20 | static getDerivedStateFromError() { 21 | return { hasError: true } 22 | } 23 | 24 | render() { 25 | if (this.state.hasError) { 26 | return "error" 27 | } 28 | 29 | return this.props.children 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/core/src/test-helpers/pipeableStreamToObservable.ts: -------------------------------------------------------------------------------- 1 | import { PipeableStream } from "react-dom/server" 2 | import { Observable, scan } from "rxjs" 3 | import { PassThrough } from "stream" 4 | 5 | export function pipeableStreamToObservable( 6 | stream: PipeableStream, 7 | ): Observable { 8 | return new Observable((subscriber) => { 9 | const passthrough = new PassThrough() 10 | const sub = readStream$(passthrough) 11 | .pipe(scan((acc, v) => acc + v, "")) 12 | .subscribe(subscriber) 13 | 14 | stream.pipe(passthrough) 15 | 16 | return () => { 17 | sub.unsubscribe() 18 | } 19 | }) 20 | } 21 | 22 | function readStream$(stream: NodeJS.ReadableStream) { 23 | return new Observable((subscriber) => { 24 | const dataHandler = (data: T) => subscriber.next(data) 25 | stream.addListener("data", dataHandler) 26 | 27 | const errorHandler = (error: any) => subscriber.error(error) 28 | stream.addListener("error", errorHandler) 29 | 30 | const closeHandler = () => subscriber.complete() 31 | stream.addListener("close", closeHandler) 32 | stream.addListener("end", closeHandler) 33 | 34 | return () => { 35 | stream.removeListener("data", dataHandler) 36 | stream.removeListener("error", errorHandler) 37 | stream.removeListener("close", closeHandler) 38 | stream.removeListener("end", closeHandler) 39 | } 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /packages/core/src/useStateObservable.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DefaultedStateObservable, 3 | liftSuspense, 4 | NoSubscribersError, 5 | StateObservable, 6 | StatePromise, 7 | SUSPENSE, 8 | } from "@rx-state/core" 9 | import { useRef, useState } from "react" 10 | import useSyncExternalStore from "./internal/useSyncExternalStore" 11 | import { useSubscription } from "./Subscribe" 12 | 13 | type VoidCb = () => void 14 | 15 | interface Ref { 16 | source$: StateObservable 17 | args: [ 18 | (cb: VoidCb) => VoidCb, 19 | () => Exclude, 20 | () => Exclude, 21 | ] 22 | } 23 | 24 | export const useStateObservable = ( 25 | source$: StateObservable, 26 | ): Exclude => { 27 | const subscription = useSubscription() 28 | const [, setError] = useState() 29 | const callbackRef = useRef | undefined>(undefined) 30 | 31 | if (!callbackRef.current) { 32 | const getValue = (src: StateObservable) => { 33 | const result = src.getValue() 34 | if (result instanceof StatePromise) 35 | throw result.catch((e) => { 36 | if (e instanceof NoSubscribersError) return e 37 | throw e 38 | }) 39 | return result as any 40 | } 41 | 42 | const gv: () => Exclude = () => { 43 | const src = callbackRef.current!.source$ as DefaultedStateObservable 44 | if (!src.getRefCount() && !src.getDefaultValue) { 45 | if (!subscription) throw new Error("Missing Subscribe!") 46 | subscription(src) 47 | } 48 | return getValue(src) 49 | } 50 | 51 | callbackRef.current = { 52 | source$: null as any, 53 | args: [, gv, gv] as any, 54 | } 55 | } 56 | 57 | const ref = callbackRef.current 58 | if (ref.source$ !== source$) { 59 | ref.source$ = source$ 60 | ref.args[0] = (next: () => void) => { 61 | const subscription = liftSuspense()(source$).subscribe({ 62 | next, 63 | error: (e) => { 64 | setError(() => { 65 | throw e 66 | }) 67 | }, 68 | }) 69 | return () => { 70 | subscription.unsubscribe() 71 | } 72 | } 73 | } 74 | 75 | return useSyncExternalStore(...ref!.args) 76 | } 77 | -------------------------------------------------------------------------------- /packages/core/tsconfig-build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["**/*.test.*", "**/test-helpers/*.*"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "compilerOptions": { 4 | "target": "esnext", 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./src", 11 | "strict": true, 12 | "noImplicitAny": true, 13 | "strictNullChecks": true, 14 | "strictFunctionTypes": true, 15 | "strictPropertyInitialization": true, 16 | "noImplicitThis": true, 17 | "alwaysStrict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noImplicitReturns": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "moduleResolution": "node", 23 | "baseUrl": "./src", 24 | "paths": { 25 | "*": ["src/*", "node_modules/*"] 26 | }, 27 | "jsx": "react", 28 | "esModuleInterop": true 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/dom/README.md: -------------------------------------------------------------------------------- 1 | # @react-rxjs/dom 2 | 3 | Please visit the website: https://react-rxjs.org 4 | 5 | ## Installation 6 | 7 | npm install @react-rxjs/dom 8 | -------------------------------------------------------------------------------- /packages/dom/dist/index.cjs: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | if (process.env.NODE_ENV === "production") { 4 | module.exports = require("./dom.cjs.production.min.js") 5 | } else { 6 | module.exports = require("./dom.cjs.development.js") 7 | } 8 | -------------------------------------------------------------------------------- /packages/dom/dist/index.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | if (process.env.NODE_ENV === "production") { 4 | module.exports = require("./dom.cjs.production.min.js") 5 | } else { 6 | module.exports = require("./dom.cjs.development.js") 7 | } 8 | -------------------------------------------------------------------------------- /packages/dom/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1.10", 3 | "repository": { 4 | "type": "git", 5 | "url": "git+https://github.com/re-rxjs/react-rxjs.git" 6 | }, 7 | "license": "MIT", 8 | "sideEffects": false, 9 | "exports": { 10 | ".": { 11 | "node": { 12 | "module": "./dist/dom.es2017.js", 13 | "import": "./dist/dom.es2019.mjs", 14 | "require": "./dist/index.cjs" 15 | }, 16 | "types": "./dist/index.d.ts", 17 | "default": "./dist/dom.es2017.js" 18 | }, 19 | "./package.json": "./package.json" 20 | }, 21 | "module": "./dist/dom.es2017.js", 22 | "main": "./dist/index.js", 23 | "types": "./dist/index.d.ts", 24 | "files": [ 25 | "dist" 26 | ], 27 | "scripts": { 28 | "start": "tsdx watch", 29 | "build": "npm run build:ts && npm run build:esm2017 && npm run build:esm2019 && npm run build:cjs:dev && npm run build:cjs:prod", 30 | "build:esm2019": "esbuild src/index.tsx --bundle --outfile=./dist/dom.es2019.mjs --target=es2019 --external:react --external:rxjs --external:react-dom --format=esm --sourcemap", 31 | "build:esm2017": "esbuild src/index.tsx --bundle --outfile=./dist/dom.es2017.js --target=es2017 --external:react --external:rxjs --external:react-dom --format=esm --sourcemap", 32 | "build:cjs:dev": "esbuild src/index.tsx --bundle --outfile=./dist/dom.cjs.development.js --target=es2015 --external:react --external:rxjs --external:react-dom --format=cjs --sourcemap", 33 | "build:cjs:prod": "esbuild src/index.tsx --bundle --outfile=./dist/dom.cjs.production.min.js --target=es2015 --external:react --external:rxjs --external:react-dom --format=cjs --minify --sourcemap", 34 | "build:ts": "tsc -p ./tsconfig.json --outDir ./dist --skipLibCheck --emitDeclarationOnly", 35 | "test": "vitest run --coverage", 36 | "test:watch": "vitest watch", 37 | "lint": "prettier --check README.md \"src/**/*.{js,jsx,ts,tsx,json,md}\"", 38 | "format": "prettier --write README.md \"src/**/*.{js,jsx,ts,tsx,json,md}\"", 39 | "prepack": "npm run build" 40 | }, 41 | "peerDependencies": { 42 | "react": ">=16.8.0", 43 | "react-dom": ">=16.8.0", 44 | "rxjs": ">=6" 45 | }, 46 | "prettier": { 47 | "printWidth": 80, 48 | "semi": false, 49 | "trailingComma": "all" 50 | }, 51 | "name": "@react-rxjs/dom", 52 | "authors": [ 53 | "Josep M Sobrepere (https://github.com/josepot)", 54 | "Victor Oliva (https://github.com/voliva)" 55 | ], 56 | "devDependencies": { 57 | "@react-rxjs/core": "0.10.8" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /packages/dom/src/batchUpdates.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component, ErrorInfo, useEffect } from "react" 2 | import { Observable, throwError, concat, Subject } from "rxjs" 3 | import { mergeMapTo, take, filter, catchError } from "rxjs/operators" 4 | import { bind, Subscribe } from "@react-rxjs/core" 5 | import { batchUpdates } from "./" 6 | import { act, render, screen } from "@testing-library/react" 7 | import { describe, expect, test, beforeAll, vi } from "vitest" 8 | 9 | const wait = (ms: number) => new Promise((res) => setTimeout(res, ms)) 10 | 11 | const next$ = new Subject<{ batched: boolean; error: boolean }>() 12 | const [useLatestNumber, latestNumber$] = bind( 13 | (batched: boolean, error: boolean) => { 14 | return concat( 15 | [0], 16 | next$.pipe( 17 | filter((x) => x.batched === batched && x.error === error), 18 | take(1), 19 | mergeMapTo(error ? throwError("controlled error") : [1, 2, 3, 4, 5]), 20 | batched ? batchUpdates() : (x: Observable) => x, 21 | ), 22 | ) 23 | }, 24 | ) 25 | 26 | class TestErrorBoundary extends Component< 27 | { 28 | children?: React.ReactNode | undefined 29 | onError: (error: Error, errorInfo: ErrorInfo) => void 30 | }, 31 | { 32 | hasError: boolean 33 | } 34 | > { 35 | state = { 36 | hasError: false, 37 | } 38 | 39 | componentDidCatch(error: Error, errorInfo: ErrorInfo) { 40 | this.props.onError(error, errorInfo) 41 | } 42 | 43 | static getDerivedStateFromError() { 44 | return { hasError: true } 45 | } 46 | 47 | render() { 48 | if (this.state.hasError) { 49 | return "error" 50 | } 51 | 52 | return this.props.children 53 | } 54 | } 55 | 56 | interface Props { 57 | onRender: () => void 58 | batched?: boolean 59 | error?: boolean 60 | } 61 | const Grandson: React.FC = ({ onRender, batched, error }) => { 62 | const latestNumber = useLatestNumber(!!batched, !!error) 63 | useEffect(onRender) 64 | return
Grandson {latestNumber}
65 | } 66 | 67 | const Son: React.FC = (props) => { 68 | const latestNumber = useLatestNumber(!!props.batched, !!props.error) 69 | useEffect(props.onRender) 70 | return ( 71 |
72 | Son {latestNumber} 73 | 74 |
75 | ) 76 | } 77 | 78 | const Father: React.FC = (props) => { 79 | const latestNumber = useLatestNumber(!!props.batched, !!props.error) 80 | useEffect(props.onRender) 81 | return ( 82 |
83 | Father {latestNumber} 84 | 85 |
86 | ) 87 | } 88 | 89 | describe("batchUpdates", () => { 90 | const originalError = console.error 91 | beforeAll(() => { 92 | console.error = (...args: any) => { 93 | if ( 94 | /inside a test was not wrapped in act/.test(args[0]) || 95 | /Uncaught 'controlled error'/.test(args[0]) || 96 | /using the error boundary .* TestErrorBoundary/.test(args[0]) 97 | ) { 98 | return 99 | } 100 | originalError.call(console, ...args) 101 | } 102 | }) 103 | 104 | test.skip("it triggers nested updates when batchUpdates is not used", async () => { 105 | const mockFn = vi.fn() 106 | render( 107 | 108 | 109 | , 110 | ) 111 | expect(screen.queryByText("Father 0")).not.toBeNull() 112 | expect(screen.queryByText("Son 0")).not.toBeNull() 113 | expect(screen.queryByText("Grandson 0")).not.toBeNull() 114 | expect(mockFn).toHaveBeenCalledTimes(3) 115 | 116 | await act(async () => { 117 | await wait(100) 118 | next$.next({ batched: false, error: false }) 119 | }) 120 | 121 | expect(screen.queryByText("Father 5")).not.toBeNull() 122 | expect(screen.queryByText("Son 5")).not.toBeNull() 123 | expect(screen.queryByText("Grandson 5")).not.toBeNull() 124 | expect(mockFn.mock.calls.length > 20).toBe(true) 125 | }) 126 | 127 | test("batchUpdates prevents unnecessary updates", async () => { 128 | const mockFn = vi.fn() 129 | render( 130 | 131 | 132 | , 133 | ) 134 | 135 | expect(screen.queryByText("Father 0")).not.toBeNull() 136 | expect(screen.queryByText("Son 0")).not.toBeNull() 137 | expect(screen.queryByText("Grandson 0")).not.toBeNull() 138 | expect(mockFn).toHaveBeenCalledTimes(3) 139 | 140 | await act(async () => { 141 | await wait(100) 142 | next$.next({ batched: true, error: false }) 143 | }) 144 | 145 | expect(screen.queryByText("Father 5")).not.toBeNull() 146 | expect(screen.queryByText("Son 5")).not.toBeNull() 147 | expect(screen.queryByText("Grandson 5")).not.toBeNull() 148 | expect(mockFn).toHaveBeenCalledTimes(6) 149 | }) 150 | 151 | test("batchUpdates doesn't get in the way of Error Boundaries", async () => { 152 | const mockFn = vi.fn() 153 | const errorCallback = vi.fn() 154 | latestNumber$(true, true) 155 | .pipe(catchError(() => [])) 156 | .subscribe() 157 | render( 158 | 159 | 160 | , 161 | ) 162 | expect(screen.queryByText("Father 0")).not.toBeNull() 163 | expect(screen.queryByText("Son 0")).not.toBeNull() 164 | expect(screen.queryByText("Grandson 0")).not.toBeNull() 165 | expect(mockFn).toHaveBeenCalledTimes(3) 166 | 167 | await act(async () => { 168 | next$.next({ batched: true, error: true }) 169 | }) 170 | 171 | expect(errorCallback).toHaveBeenCalledWith( 172 | "controlled error", 173 | expect.any(Object), 174 | ) 175 | expect(mockFn).toHaveBeenCalledTimes(3) 176 | }) 177 | }) 178 | -------------------------------------------------------------------------------- /packages/dom/src/batchUpdates.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from "rxjs" 2 | import { unstable_batchedUpdates } from "react-dom" 3 | 4 | /** 5 | * A RxJS pipeable operator which observes the source observable on 6 | * an asapScheduler and uses `ReactDom.unstable_batchedUpdates` to emit the 7 | * values. It's useful for observing streams of events that come from outside 8 | * of ReactDom event-handlers. 9 | * 10 | * @remarks This operator will be deprecated when React 17 is released 11 | * (or whenever React CM is released). The reason being that React Concurrent Mode 12 | * automatically batches all synchronous updates. Meaning that with React CM, 13 | * observing a stream through the asapScheduler accomplishes the same thing. 14 | */ 15 | export const batchUpdates = 16 | () => 17 | (source$: Observable): Observable => { 18 | return new Observable((observer) => { 19 | const obs = { 20 | n: (v: T) => observer.next(v), 21 | c: () => observer.complete(), 22 | e: (e: any) => observer.error(e), 23 | } 24 | let queue: ["n" | "c" | "e", any?][] = [] 25 | let promise: Promise | null = null 26 | const flush = () => { 27 | promise = null 28 | const originalQueue = queue 29 | queue = [] 30 | unstable_batchedUpdates(() => { 31 | originalQueue.forEach(([prop, val]) => obs[prop](val)) 32 | }) 33 | } 34 | const push = (type: "n" | "c" | "e") => (val?: any) => { 35 | queue.push([type, val]) 36 | if (!promise) { 37 | promise = Promise.resolve().then(flush) 38 | } 39 | } 40 | return source$.subscribe(push("n"), push("e"), push("c")) 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /packages/dom/src/index.tsx: -------------------------------------------------------------------------------- 1 | export { batchUpdates } from "./batchUpdates" 2 | -------------------------------------------------------------------------------- /packages/dom/tsconfig-build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["**/*.test.*", "**/test-helpers/*.*"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/dom/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "compilerOptions": { 4 | "target": "esnext", 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./src", 11 | "strict": true, 12 | "noImplicitAny": true, 13 | "strictNullChecks": true, 14 | "strictFunctionTypes": true, 15 | "strictPropertyInitialization": true, 16 | "noImplicitThis": true, 17 | "alwaysStrict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noImplicitReturns": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "moduleResolution": "node", 23 | "baseUrl": "./src", 24 | "paths": { 25 | "*": ["src/*", "node_modules/*"] 26 | }, 27 | "jsx": "react", 28 | "esModuleInterop": true 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/utils/README.md: -------------------------------------------------------------------------------- 1 | # @react-rxjs/utils 2 | 3 | Please visit the website: https://react-rxjs.org 4 | 5 | ## Installation 6 | 7 | npm install @react-rxjs/utils 8 | -------------------------------------------------------------------------------- /packages/utils/dist/index.cjs: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | if (process.env.NODE_ENV === "production") { 4 | module.exports = require("./utils.cjs.production.min.js") 5 | } else { 6 | module.exports = require("./utils.cjs.development.js") 7 | } 8 | -------------------------------------------------------------------------------- /packages/utils/dist/index.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | if (process.env.NODE_ENV === "production") { 4 | module.exports = require("./utils.cjs.production.min.js") 5 | } else { 6 | module.exports = require("./utils.cjs.development.js") 7 | } 8 | -------------------------------------------------------------------------------- /packages/utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.9.7", 3 | "type": "module", 4 | "repository": { 5 | "type": "git", 6 | "url": "git+https://github.com/re-rxjs/react-rxjs.git" 7 | }, 8 | "license": "MIT", 9 | "sideEffects": false, 10 | "exports": { 11 | ".": { 12 | "node": { 13 | "module": "./dist/utils.es2017.js", 14 | "import": "./dist/utils.es2019.mjs", 15 | "require": "./dist/index.cjs" 16 | }, 17 | "types": "./dist/index.d.ts", 18 | "default": "./dist/utils.es2017.js" 19 | }, 20 | "./package.json": "./package.json" 21 | }, 22 | "module": "./dist/utils.es2017.js", 23 | "main": "./dist/index.js", 24 | "types": "./dist/index.d.ts", 25 | "files": [ 26 | "dist" 27 | ], 28 | "scripts": { 29 | "build": "npm run build:ts && npm run build:esm2017 && npm run build:esm2019 && npm run build:cjs:dev && npm run build:cjs:prod", 30 | "build:esm2019": "esbuild src/index.tsx --bundle --outfile=./dist/utils.es2019.mjs --target=es2019 --external:react --external:rxjs --external:@react-rxjs/core --format=esm --sourcemap", 31 | "build:esm2017": "esbuild src/index.tsx --bundle --outfile=./dist/utils.es2017.js --target=es2017 --external:react --external:rxjs --external:@react-rxjs/core --format=esm --sourcemap", 32 | "build:cjs:dev": "esbuild src/index.tsx --bundle --outfile=./dist/utils.cjs.development.js --target=es2015 --external:react --external:rxjs --external:@react-rxjs/core --format=cjs --sourcemap", 33 | "build:cjs:prod": "esbuild src/index.tsx --bundle --outfile=./dist/utils.cjs.production.min.js --target=es2015 --external:react --external:rxjs --external:@react-rxjs/core --format=cjs --minify --sourcemap", 34 | "build:ts": "tsc -p ./tsconfig-build.json --outDir ./dist --skipLibCheck --emitDeclarationOnly", 35 | "test": "vitest run --coverage", 36 | "test:watch": "vitest watch", 37 | "lint": "prettier --check README.md \"src/**/*.{js,jsx,ts,tsx,json,md}\"", 38 | "format": "prettier --write README.md \"src/**/*.{js,jsx,ts,tsx,json,md}\"", 39 | "prepack": "npm run build" 40 | }, 41 | "peerDependencies": { 42 | "@react-rxjs/core": ">=0.1.0", 43 | "react": ">=16.8.0", 44 | "rxjs": ">=6" 45 | }, 46 | "prettier": { 47 | "printWidth": 80, 48 | "semi": false, 49 | "trailingComma": "all" 50 | }, 51 | "name": "@react-rxjs/utils", 52 | "authors": [ 53 | "Josep M Sobrepere (https://github.com/josepot)", 54 | "Victor Oliva (https://github.com/voliva)" 55 | ], 56 | "devDependencies": { 57 | "@react-rxjs/core": "0.10.8" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /packages/utils/src/combineKeys.test.ts: -------------------------------------------------------------------------------- 1 | import { concat, NEVER, Observable, of } from "rxjs" 2 | import { map, scan } from "rxjs/operators" 3 | import { TestScheduler } from "rxjs/testing" 4 | import { combineKeys, KeyChanges } from "./" 5 | import { describe, expect, it } from "vitest" 6 | 7 | const scheduler = () => 8 | new TestScheduler((actual, expected) => { 9 | expect(actual).toEqual(expected) 10 | }) 11 | 12 | describe("combineKeys", () => { 13 | it("emits a map with the latest value of the stream of each key", () => { 14 | scheduler().run(({ expectObservable, cold }) => { 15 | const keys = cold(" ab---cd---").pipe( 16 | scan((acc, v) => [...acc, v], [] as Array), 17 | ) 18 | const a = cold(" --1---2---") 19 | const b = cold(" ---------") 20 | const c = cold(" 1----") 21 | const d = cold(" 9---") 22 | const expectedStr = "--e--f(gh)" 23 | 24 | const innerStreams: Record> = { a, b, c, d } 25 | 26 | const result = combineKeys( 27 | keys, 28 | (v): Observable => innerStreams[v], 29 | ).pipe(map((x) => Object.fromEntries(x.entries()))) 30 | 31 | expectObservable(result).toBe(expectedStr, { 32 | e: { a: "1" }, 33 | f: { a: "1", c: "1" }, 34 | g: { a: "2", c: "1" }, 35 | h: { a: "2", c: "1", d: "9" }, 36 | }) 37 | }) 38 | }) 39 | 40 | it("doesn't emit if the inner value hasn't changed", () => { 41 | scheduler().run(({ expectObservable, cold }) => { 42 | const keys = cold(" ab---cd---").pipe( 43 | scan((acc, v) => [...acc, v], [] as Array), 44 | ) 45 | const a = cold(" --11112---") 46 | const b = cold(" ---------") 47 | const c = cold(" 1----") 48 | const d = cold(" 9---") 49 | const expectedStr = "--e--f(gh)" 50 | 51 | const innerStreams: Record> = { a, b, c, d } 52 | 53 | const result = combineKeys( 54 | keys, 55 | (v): Observable => innerStreams[v], 56 | ).pipe(map((x) => Object.fromEntries(x.entries()))) 57 | 58 | expectObservable(result).toBe(expectedStr, { 59 | e: { a: "1" }, 60 | f: { a: "1", c: "1" }, 61 | g: { a: "2", c: "1" }, 62 | h: { a: "2", c: "1", d: "9" }, 63 | }) 64 | }) 65 | }) 66 | 67 | it("does not emit more than once synchronously on subscribe", () => { 68 | scheduler().run(({ expectObservable, cold }) => { 69 | const keys = concat(of(["a", "b", "c"]), NEVER) 70 | const a = of("1", "2", "3") 71 | const b = of("4") 72 | const c = cold(" ---5") 73 | const expectedStr = "e--f" 74 | 75 | const innerStreams: Record> = { a, b, c } 76 | 77 | const result = combineKeys( 78 | keys, 79 | (v): Observable => innerStreams[v], 80 | ).pipe(map((x) => Object.fromEntries(x.entries()))) 81 | 82 | expectObservable(result).toBe(expectedStr, { 83 | e: { a: "3", b: "4" }, 84 | f: { a: "3", b: "4", c: "5" }, 85 | }) 86 | }) 87 | }) 88 | 89 | it("removes the entry of a key when that key is removed from the source", () => { 90 | scheduler().run(({ expectObservable, cold }) => { 91 | const keys = cold(" a-b---c---").pipe(map((v) => [v])) 92 | const a = cold(" ----------") 93 | const b = cold(" -1-2-3-4") 94 | const c = cold(" -2-3") 95 | const expectedStr = "---e-fgh-i" 96 | 97 | const innerStreams: Record> = { a, b, c } 98 | 99 | const result = combineKeys( 100 | keys, 101 | (v): Observable => innerStreams[v], 102 | ).pipe(map((x) => Object.fromEntries(x.entries()))) 103 | 104 | expectObservable(result).toBe(expectedStr, { 105 | e: { b: "1" }, 106 | f: { b: "2" }, 107 | g: {}, 108 | h: { c: "2" }, 109 | i: { c: "3" }, 110 | }) 111 | }) 112 | }) 113 | 114 | it("completes when the key stream completes", () => { 115 | scheduler().run(({ expectObservable, cold }) => { 116 | const keys = cold(" a-b---|").pipe( 117 | scan((acc, v) => [...acc, v], [] as Array), 118 | ) 119 | const a = cold(" -1-----") 120 | const b = cold(" -1---") 121 | const expectedStr = "-e-f--|" 122 | 123 | const innerStreams: Record> = { a, b } 124 | 125 | const result = combineKeys( 126 | keys, 127 | (v): Observable => innerStreams[v], 128 | ).pipe(map((x) => Object.fromEntries(x.entries()))) 129 | 130 | expectObservable(result).toBe(expectedStr, { 131 | e: { a: "1" }, 132 | f: { a: "1", b: "1" }, 133 | }) 134 | }) 135 | }) 136 | 137 | it("propagates errors from the inner streams", () => { 138 | scheduler().run(({ expectObservable, cold }) => { 139 | const keys = cold(" a-b---|").pipe( 140 | scan((acc, v) => [...acc, v], [] as Array), 141 | ) 142 | const a = cold(" -1-----") 143 | const b = cold(" -#") 144 | const expectedStr = "-e-#" 145 | 146 | const innerStreams: Record> = { a, b } 147 | 148 | const result = combineKeys( 149 | keys, 150 | (v): Observable => innerStreams[v], 151 | ).pipe(map((x) => Object.fromEntries(x.entries()))) 152 | 153 | expectObservable(result).toBe(expectedStr, { 154 | e: { a: "1" }, 155 | }) 156 | }) 157 | }) 158 | 159 | it("propagates errors from the key stream", () => { 160 | scheduler().run(({ expectObservable, cold }) => { 161 | const keys = cold(" a-b#").pipe( 162 | scan((acc, v) => [...acc, v], [] as Array), 163 | ) 164 | const a = cold(" -1--") 165 | const b = cold(" -1") 166 | const expectedStr = "-e-#" 167 | 168 | const innerStreams: Record> = { a, b } 169 | 170 | const result = combineKeys( 171 | keys, 172 | (v): Observable => innerStreams[v], 173 | ).pipe(map((x) => Object.fromEntries(x.entries()))) 174 | 175 | expectObservable(result).toBe(expectedStr, { 176 | e: { a: "1" }, 177 | }) 178 | }) 179 | }) 180 | 181 | it("accounts for reentrant keys", () => { 182 | scheduler().run(({ expectObservable, cold }) => { 183 | const activeKeys = { 184 | a: ["a"], 185 | b: ["a", "b"], 186 | z: [], 187 | } 188 | 189 | const keys = cold(" abzab", activeKeys) 190 | const a = cold(" 1----") 191 | const b = cold(" 2---") 192 | const expectedStr = "efgef" 193 | 194 | const innerStreams: Record> = { a, b } 195 | 196 | const result = combineKeys( 197 | keys, 198 | (v): Observable => innerStreams[v], 199 | ).pipe(map((x) => Object.fromEntries(x.entries()))) 200 | 201 | expectObservable(result).toBe(expectedStr, { 202 | e: { a: "1" }, 203 | f: { a: "1", b: "2" }, 204 | g: {}, 205 | }) 206 | }) 207 | }) 208 | 209 | it("emits an empty map if the initial keys are an empty iterator", () => { 210 | scheduler().run(({ expectObservable, cold }) => { 211 | const activeKeys = { 212 | a: ["a"], 213 | b: ["a", "b"], 214 | z: [], 215 | } 216 | 217 | const keys = cold(" zzabzzab", activeKeys) 218 | const a = cold(" 1----") 219 | const b = cold(" 2---") 220 | const expectedStr = "g-efg-ef" 221 | 222 | const innerStreams: Record> = { a, b } 223 | 224 | const result = combineKeys( 225 | keys, 226 | (v): Observable => innerStreams[v], 227 | ).pipe(map((x) => Object.fromEntries(x.entries()))) 228 | 229 | expectObservable(result).toBe(expectedStr, { 230 | e: { a: "1" }, 231 | f: { a: "1", b: "2" }, 232 | g: {}, 233 | }) 234 | }) 235 | }) 236 | 237 | describe("change set", () => { 238 | it("contains all of the keys initially present in the stream", () => { 239 | scheduler().run(({ expectObservable }) => { 240 | const keys = concat(of(["a", "b", "c"]), NEVER) 241 | 242 | const result = combineKeys(keys, (v) => of(v)).pipe( 243 | map((x) => Array.from(x.changes)), 244 | ) 245 | 246 | expectObservable(result).toBe("x", { 247 | x: ["a", "b", "c"], 248 | }) 249 | }) 250 | }) 251 | 252 | it("only contains those values that have changed from the previous emission", () => { 253 | scheduler().run(({ expectObservable, cold }) => { 254 | const keys = concat(of(["a", "b", "c"]), NEVER) 255 | const a = concat(["1"], cold("--2--")) 256 | const b = concat(["1"], cold("---2-")) 257 | const c = concat(["1"], cold("-----")) 258 | const expected = " x-yz-" 259 | const streams: Record> = { a, b, c } 260 | 261 | const result = combineKeys(keys, (v) => streams[v]).pipe( 262 | map((x) => Array.from(x.changes)), 263 | ) 264 | 265 | expectObservable(result).toBe(expected, { 266 | x: ["a", "b", "c"], 267 | y: ["a"], 268 | z: ["b"], 269 | }) 270 | }) 271 | }) 272 | 273 | it("contains removed keys", () => { 274 | scheduler().run(({ expectObservable, cold }) => { 275 | const keys = cold("x--y-", { 276 | x: ["a", "b", "c"], 277 | y: ["b"], 278 | }) 279 | const expected = " x--y-" 280 | 281 | const result = combineKeys(keys, (v) => of(v)).pipe( 282 | map((x) => Array.from(x.changes)), 283 | ) 284 | 285 | expectObservable(result).toBe(expected, { 286 | x: ["a", "b", "c"], 287 | y: ["a", "c"], 288 | }) 289 | }) 290 | }) 291 | }) 292 | 293 | describe("with deltas", () => { 294 | it("emits a map with the latest value of the stream of each key added", () => { 295 | scheduler().run(({ expectObservable, cold }) => { 296 | const keys = cold>(" ab---cd---", { 297 | a: { 298 | type: "add", 299 | keys: ["a"], 300 | }, 301 | b: { 302 | type: "add", 303 | keys: ["b"], 304 | }, 305 | c: { 306 | type: "add", 307 | keys: ["c"], 308 | }, 309 | d: { 310 | type: "add", 311 | keys: ["d"], 312 | }, 313 | }) 314 | const a = cold(" --1---2---") 315 | const b = cold(" ---------") 316 | const c = cold(" 1----") 317 | const d = cold(" 9---") 318 | const expectedStr = "--e--f(gh)" 319 | 320 | const innerStreams: Record> = { a, b, c, d } 321 | 322 | const result = combineKeys( 323 | keys, 324 | (v): Observable => innerStreams[v], 325 | ).pipe(map((x) => Object.fromEntries(x.entries()))) 326 | 327 | expectObservable(result).toBe(expectedStr, { 328 | e: { a: "1" }, 329 | f: { a: "1", c: "1" }, 330 | g: { a: "2", c: "1" }, 331 | h: { a: "2", c: "1", d: "9" }, 332 | }) 333 | }) 334 | }) 335 | 336 | it("removes the entry of a key when that key is removed", () => { 337 | scheduler().run(({ expectObservable, cold }) => { 338 | const expectedStr = " e-f-g-h-" 339 | const keys = cold>("a-b-c-d-", { 340 | a: { 341 | type: "add", 342 | keys: ["a"], 343 | }, 344 | b: { 345 | type: "add", 346 | keys: ["b"], 347 | }, 348 | c: { 349 | type: "remove", 350 | keys: ["a"], 351 | }, 352 | d: { 353 | type: "remove", 354 | keys: ["b"], 355 | }, 356 | }) 357 | 358 | const result = combineKeys(keys, (v): Observable => of(v)).pipe( 359 | map((x) => Object.fromEntries(x.entries())), 360 | ) 361 | 362 | expectObservable(result).toBe(expectedStr, { 363 | e: { a: "a" }, 364 | f: { a: "a", b: "b" }, 365 | g: { b: "b" }, 366 | h: {}, 367 | }) 368 | }) 369 | }) 370 | 371 | it("accepts more than one key change at a time", () => { 372 | scheduler().run(({ expectObservable, cold }) => { 373 | const expectedStr = " e-f-" 374 | const keys = cold>("a-b-", { 375 | a: { 376 | type: "add", 377 | keys: ["a", "b", "c"], 378 | }, 379 | b: { 380 | type: "remove", 381 | keys: ["c", "b"], 382 | }, 383 | }) 384 | 385 | const result = combineKeys(keys, (v): Observable => of(v)).pipe( 386 | map((x) => Object.fromEntries(x.entries())), 387 | ) 388 | 389 | expectObservable(result).toBe(expectedStr, { 390 | e: { a: "a", b: "b", c: "c" }, 391 | f: { a: "a" }, 392 | }) 393 | }) 394 | }) 395 | 396 | it("omits removing keys that don't exist", () => { 397 | scheduler().run(({ expectObservable, cold }) => { 398 | const expectedStr = " e---" 399 | const keys = cold>("a-b-", { 400 | a: { 401 | type: "add", 402 | keys: ["a"], 403 | }, 404 | b: { 405 | type: "remove", 406 | keys: ["b"], 407 | }, 408 | }) 409 | 410 | const result = combineKeys(keys, (v): Observable => of(v)).pipe( 411 | map((x) => Object.fromEntries(x.entries())), 412 | ) 413 | 414 | expectObservable(result).toBe(expectedStr, { 415 | e: { a: "a" }, 416 | }) 417 | }) 418 | }) 419 | }) 420 | }) 421 | -------------------------------------------------------------------------------- /packages/utils/src/combineKeys.ts: -------------------------------------------------------------------------------- 1 | import { KeyChanges } from "./partitionByKey" 2 | import { Observable, Subscription } from "rxjs" 3 | 4 | export interface MapWithChanges extends Map { 5 | changes: Set 6 | } 7 | 8 | /** 9 | * Creates a stream that combines the result of the streams from each key of the input stream. 10 | * 11 | * @param keys$ Stream of the list of keys to subscribe to. 12 | * @param getInner$ Function that returns the stream for each key. 13 | * @returns An stream with a map containing the latest value from the stream of each key. 14 | */ 15 | export const combineKeys = ( 16 | keys$: Observable | KeyChanges>, 17 | getInner$: (key: K) => Observable, 18 | ): Observable> => 19 | new Observable((observer) => { 20 | const innerSubscriptions = new Map() 21 | let changes = new Set() 22 | const currentValue = new Map() 23 | let updatingSource = false 24 | let isPristine = true 25 | 26 | const next = () => { 27 | if (!updatingSource) { 28 | const result = Object.assign(currentValue, { 29 | changes, 30 | }) 31 | changes = new Set() 32 | isPristine = false 33 | observer.next(result) 34 | } 35 | } 36 | 37 | const subscription = keys$.subscribe( 38 | (nextKeysArr) => { 39 | updatingSource = true 40 | 41 | const keys = new Set( 42 | inputIsKeyChanges(nextKeysArr) ? nextKeysArr.keys : nextKeysArr, 43 | ) 44 | 45 | if (inputIsKeyChanges(nextKeysArr)) { 46 | if (nextKeysArr.type === "remove") { 47 | keys.forEach((key) => { 48 | const sub = innerSubscriptions.get(key) 49 | if (!sub) return 50 | sub.unsubscribe() 51 | innerSubscriptions.delete(key) 52 | if (currentValue.has(key)) { 53 | changes.add(key) 54 | currentValue.delete(key) 55 | } 56 | }) 57 | // Keys after this block is the list of keys to add. Clear it. 58 | keys.clear() 59 | } 60 | } else { 61 | innerSubscriptions.forEach((sub, key) => { 62 | if (!keys.has(key)) { 63 | sub.unsubscribe() 64 | innerSubscriptions.delete(key) 65 | if (currentValue.has(key)) { 66 | changes.add(key) 67 | currentValue.delete(key) 68 | } 69 | } else { 70 | keys.delete(key) 71 | } 72 | }) 73 | } 74 | 75 | keys.forEach((key) => { 76 | innerSubscriptions.set( 77 | key, 78 | getInner$(key).subscribe( 79 | (x) => { 80 | if (!currentValue.has(key) || currentValue.get(key) !== x) { 81 | changes.add(key) 82 | currentValue.set(key, x) 83 | next() 84 | } 85 | }, 86 | (e) => { 87 | observer.error(e) 88 | }, 89 | ), 90 | ) 91 | }) 92 | 93 | updatingSource = false 94 | // If there are no changes but the nextKeys are an empty iterator 95 | // and we have never emitted before, that means that the first 96 | // value that keys$ has emitted is an empty iterator, therefore 97 | // we should emit an empy Map 98 | if (changes.size || (isPristine && !keys.size)) next() 99 | }, 100 | (e) => { 101 | observer.error(e) 102 | }, 103 | () => { 104 | observer.complete() 105 | }, 106 | ) 107 | 108 | return () => { 109 | subscription.unsubscribe() 110 | innerSubscriptions.forEach((sub) => { 111 | sub.unsubscribe() 112 | }) 113 | } 114 | }) 115 | 116 | function inputIsKeyChanges( 117 | input: Iterable | KeyChanges, 118 | ): input is KeyChanges { 119 | return "type" in input && "keys" in input 120 | } 121 | -------------------------------------------------------------------------------- /packages/utils/src/contextBinder.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react" 2 | import React, { createContext, useContext } from "react" 3 | import { of } from "rxjs" 4 | import { contextBinder } from "./index" 5 | import { describe, expect, it } from "vitest" 6 | 7 | describe("contextBinder", () => { 8 | it("bounds the provided context into the first args of the hook", () => { 9 | const idContext = createContext("id1") 10 | const countContext = createContext(3) 11 | const useId = () => useContext(idContext) 12 | const useCount = () => useContext(countContext) 13 | const idCountBind = contextBinder(useId, useCount) 14 | 15 | const [useSomething] = idCountBind( 16 | (id: string, count: number, append: string) => 17 | of(Array(count).fill(id).concat(append).join("-")), 18 | "", 19 | ) 20 | 21 | const Result: React.FC = () => Result {useSomething("bar")} 22 | 23 | const Component: React.FC<{ id: string; count: number }> = ({ 24 | id, 25 | count, 26 | }) => { 27 | return ( 28 | 29 | 30 | 31 | 32 | 33 | ) 34 | } 35 | 36 | render() 37 | 38 | expect(screen.queryByText("Result foo-foo-foo-foo-bar")).not.toBeNull() 39 | }) 40 | 41 | it("the returned function matches the signature of the original one", () => { 42 | const idContext = createContext("id1") 43 | const countContext = createContext(3) 44 | const useId = () => useContext(idContext) 45 | const useCount = () => useContext(countContext) 46 | const idCountBind = contextBinder(useId, useCount) 47 | 48 | const [, getSomething$] = idCountBind( 49 | (id: string, count: number, append: string) => 50 | of(Array(count).fill(id).concat(append).join("-")), 51 | "", 52 | ) 53 | 54 | let value = "" 55 | getSomething$("foo", 4, "bar").subscribe((v) => { 56 | value = v 57 | }) 58 | 59 | expect(value).toBe("foo-foo-foo-foo-bar") 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /packages/utils/src/contextBinder.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from "rxjs" 2 | import { bind, SUSPENSE } from "@react-rxjs/core" 3 | 4 | type SubstractTuples = A2 extends [unknown, ...infer Rest2] 5 | ? A1 extends [unknown, ...infer Rest1] 6 | ? SubstractTuples 7 | : [] 8 | : A1 9 | 10 | const execSelf = (fn: () => T) => fn() 11 | 12 | /** 13 | * Returns a version of bind where its hook will have the first parameters bound 14 | * the results of the provided functions 15 | * 16 | * @param {...React.Context} context - The React.Context that should be bound to the hook. 17 | */ 18 | export function contextBinder< 19 | A extends (() => any)[], 20 | OT extends { 21 | [K in keyof A]: A[K] extends () => infer V ? V : unknown 22 | }, 23 | >( 24 | ...args: A 25 | ): ( 26 | getObservable: (...args: ARGS) => Observable, 27 | defaultValue?: T | undefined, 28 | ) => [ 29 | (...args: SubstractTuples) => Exclude, 30 | (...args: ARGS) => Observable, 31 | ] 32 | export function contextBinder(...args: any[]) { 33 | const useArgs = () => args.map(execSelf) 34 | return function () { 35 | const [hook, getter] = bind.apply(null, arguments as any) as any 36 | return [(...args: any[]) => (hook as any)(...useArgs(), ...args), getter] 37 | } as any 38 | } 39 | -------------------------------------------------------------------------------- /packages/utils/src/createKeyedSignal.spec.ts: -------------------------------------------------------------------------------- 1 | import { createKeyedSignal } from "./" 2 | import { describe, expect, it } from "vitest" 3 | 4 | describe("createKeyedSignal", () => { 5 | it("receives a key selector and a mapper and returns a tuple with an observable-getter and its corresponding event-emitter", () => { 6 | const [getFooBar$, onFooBar] = createKeyedSignal( 7 | (x) => x.key, 8 | (foo: number, bar: string, key: string) => ({ foo, bar, key }), 9 | ) 10 | 11 | let receivedValue1 12 | let nHits1 = 0 13 | const subscription1 = getFooBar$("key").subscribe((val) => { 14 | receivedValue1 = val 15 | nHits1++ 16 | }) 17 | 18 | expect(receivedValue1).toBe(undefined) 19 | onFooBar(0, "1", "key") 20 | expect(receivedValue1).toEqual({ foo: 0, bar: "1", key: "key" }) 21 | expect(nHits1).toBe(1) 22 | 23 | let receivedValue2 24 | let nHits2 = 0 25 | const subscription2 = getFooBar$("key").subscribe((val) => { 26 | receivedValue2 = val 27 | nHits2++ 28 | }) 29 | 30 | expect(receivedValue2).toBe(undefined) 31 | 32 | onFooBar(1, "2", "key") 33 | expect(receivedValue1).toEqual({ foo: 1, bar: "2", key: "key" }) 34 | expect(nHits1).toBe(2) 35 | expect(receivedValue2).toEqual({ foo: 1, bar: "2", key: "key" }) 36 | expect(nHits2).toBe(1) 37 | 38 | onFooBar(1, "2", "key2") 39 | expect(nHits1).toBe(2) 40 | expect(nHits2).toBe(1) 41 | 42 | subscription1.unsubscribe() 43 | subscription2.unsubscribe() 44 | }) 45 | 46 | it("receives a key selector and returns a tuple with an observable-getter and its corresponding event-emitter", () => { 47 | const [getFooBar$, onFooBar] = createKeyedSignal( 48 | (signal: { key: string; foo: number; bar: string }) => signal.key, 49 | ) 50 | 51 | let receivedValue1 52 | let nHits1 = 0 53 | const subscription1 = getFooBar$("key").subscribe((val) => { 54 | receivedValue1 = val 55 | nHits1++ 56 | }) 57 | 58 | expect(receivedValue1).toBe(undefined) 59 | onFooBar({ key: "key", foo: 0, bar: "1" }) 60 | expect(receivedValue1).toEqual({ foo: 0, bar: "1", key: "key" }) 61 | expect(nHits1).toBe(1) 62 | 63 | let receivedValue2 64 | let nHits2 = 0 65 | const subscription2 = getFooBar$("key").subscribe((val) => { 66 | receivedValue2 = val 67 | nHits2++ 68 | }) 69 | 70 | expect(receivedValue2).toBe(undefined) 71 | 72 | onFooBar({ key: "key", foo: 1, bar: "2" }) 73 | expect(receivedValue1).toEqual({ foo: 1, bar: "2", key: "key" }) 74 | expect(nHits1).toBe(2) 75 | expect(receivedValue2).toEqual({ foo: 1, bar: "2", key: "key" }) 76 | expect(nHits2).toBe(1) 77 | 78 | onFooBar({ key: "key2", foo: 1, bar: "2" }) 79 | expect(nHits1).toBe(2) 80 | expect(nHits2).toBe(1) 81 | 82 | subscription1.unsubscribe() 83 | subscription2.unsubscribe() 84 | }) 85 | 86 | it("returns a tupe with a typed observable and its corresponding event-emitter for the key-value overload", () => { 87 | const [foo$, onFoo] = createKeyedSignal() 88 | let receivedValue 89 | foo$("foo").subscribe((val) => { 90 | receivedValue = val 91 | }) 92 | expect(receivedValue).toBe(undefined) 93 | onFoo("foo", 5) 94 | expect(receivedValue).toEqual(5) 95 | }) 96 | 97 | it('returns a tuple with a typed observable and its corresponding event-emitter when no "event creator" is provided', () => { 98 | const [foo$, onFoo] = createKeyedSignal() 99 | let receivedValue 100 | foo$("foo").subscribe((val) => { 101 | receivedValue = val 102 | }) 103 | expect(receivedValue).toBe(undefined) 104 | onFoo("foo") 105 | expect(receivedValue).toEqual("foo") 106 | }) 107 | }) 108 | -------------------------------------------------------------------------------- /packages/utils/src/createKeyedSignal.ts: -------------------------------------------------------------------------------- 1 | import { GroupedObservable, Observable, Observer } from "rxjs" 2 | 3 | /** 4 | * Creates a "keyed" signal. It's sugar for splitting the Observer and the Observable of a keyed signal. 5 | * 6 | * @returns [1, 2] 7 | * 1. The getter function that returns the GroupedObservable 8 | * 2. The emitter function. 9 | */ 10 | export function createKeyedSignal(): [ 11 | (key: T) => GroupedObservable, 12 | (key: T) => void, 13 | ] 14 | 15 | /** 16 | * Creates a "keyed" signal. It's sugar for splitting the Observer and the Observable of a keyed signal. 17 | * 18 | * @param keySelector a function that extracts the key from the emitted value 19 | * @returns [1, 2] 20 | * 1. The getter function that returns the GroupedObservable 21 | * 2. The emitter function. 22 | */ 23 | export function createKeyedSignal(): [ 24 | (key: K) => GroupedObservable, 25 | (key: K, value: T) => void, 26 | ] 27 | 28 | /** 29 | * Creates a "keyed" signal. It's sugar for splitting the Observer and the Observable of a keyed signal. 30 | * 31 | * @param keySelector a function that extracts the key from the emitted value 32 | * @returns [1, 2] 33 | * 1. The getter function that returns the GroupedObservable 34 | * 2. The emitter function. 35 | */ 36 | export function createKeyedSignal( 37 | keySelector: (signal: T) => K, 38 | ): [(key: K) => GroupedObservable, (signal: T) => void] 39 | 40 | /** 41 | * Creates a "keyed" signal. It's sugar for splitting the Observer and the Observable of a keyed signal. 42 | * 43 | * @param keySelector a function that extracts the key from the emitted value 44 | * @param mapper a function that maps the arguments of the emitter function to the value of the GroupedObservable 45 | * @returns [1, 2] 46 | * 1. The getter function that returns the GroupedObservable 47 | * 2. The emitter function (...args: any[]) => T. 48 | */ 49 | export function createKeyedSignal( 50 | keySelector: (signal: T) => K, 51 | mapper: (...args: A) => T, 52 | ): [(key: K) => GroupedObservable, (...args: A) => void] 53 | 54 | export function createKeyedSignal( 55 | keySelector?: (signal: T) => K, 56 | mapper?: (...args: A) => T, 57 | ): [(key: K) => GroupedObservable, (...args: A) => void] { 58 | const observersMap = new Map>>() 59 | 60 | return [ 61 | (key: K) => { 62 | const res = new Observable((observer) => { 63 | if (!observersMap.has(key)) { 64 | observersMap.set(key, new Set()) 65 | } 66 | const set = observersMap.get(key)! 67 | set.add(observer) 68 | return () => { 69 | set.delete(observer) 70 | if (set.size === 0) { 71 | observersMap.delete(key) 72 | } 73 | } 74 | }) as GroupedObservable 75 | ;(res as any).key = key 76 | return res 77 | }, 78 | (...args: A) => { 79 | const payload = mapper 80 | ? mapper(...args) 81 | : args.length === 2 82 | ? args[1] 83 | : args[0] 84 | const key = keySelector ? keySelector(payload) : args[0] 85 | observersMap.get(key)?.forEach((o) => { 86 | o.next(payload) 87 | }) 88 | }, 89 | ] 90 | } 91 | -------------------------------------------------------------------------------- /packages/utils/src/createListener.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from "rxjs" 2 | import { createSignal } from "./createSignal" 3 | 4 | /** @deprecated createListener is deprecated and it will be removed in the next version, please use createSignal. */ 5 | export function createListener
( 6 | mapper: (...args: A) => T, 7 | ): [Observable, (...args: A) => void] 8 | 9 | /** @deprecated createListener is deprecated and it will be removed in the next version, please use createSignal. */ 10 | export function createListener(): [Observable, () => void] 11 | 12 | /** @deprecated createListener is deprecated and it will be removed in the next version, please use createSignal. */ 13 | export function createListener(): [Observable, (payload: T) => void] 14 | 15 | /** 16 | * Creates a void signal. It's sugar for splitting the Observer and the Observable of a signal. 17 | * 18 | * @returns [1, 2] 19 | * 1. The Observable 20 | * 2. The emitter function. 21 | */ 22 | export function createListener(...args: any[]) { 23 | return (createSignal as any)(...args) 24 | } 25 | -------------------------------------------------------------------------------- /packages/utils/src/createSignal.spec.ts: -------------------------------------------------------------------------------- 1 | import { createSignal } from "./" 2 | import { createListener } from "./" 3 | import { describe, expect, it } from "vitest" 4 | 5 | describe("createSignal", () => { 6 | it('receives an "event creator" and it returns a tuple with an observable and its corresponding event-emitter', () => { 7 | const [fooBar$, onFooBar] = createSignal((foo: number, bar: string) => ({ 8 | foo, 9 | bar, 10 | })) 11 | let receivedValue 12 | fooBar$.subscribe((val) => { 13 | receivedValue = val 14 | }) 15 | expect(receivedValue).toBe(undefined) 16 | onFooBar(0, "1") 17 | expect(receivedValue).toEqual({ foo: 0, bar: "1" }) 18 | }) 19 | it('returns a tuple with a typed observable and its corresponding event-emitter when no "event creator" is provided', () => { 20 | const [foo$, onFoo] = createSignal() 21 | let receivedValue 22 | foo$.subscribe((val) => { 23 | receivedValue = val 24 | }) 25 | expect(receivedValue).toBe(undefined) 26 | onFoo("foo") 27 | expect(receivedValue).toEqual("foo") 28 | }) 29 | it('returns a tuple with a void observable and its corresponding event-emitter when no "event creator" and no type is provided', () => { 30 | const [clicks$, onClick] = createListener() 31 | let count = 0 32 | clicks$.subscribe(() => { 33 | count++ 34 | }) 35 | expect(count).toBe(0) 36 | onClick() 37 | expect(count).toBe(1) 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /packages/utils/src/createSignal.ts: -------------------------------------------------------------------------------- 1 | import { identity, Observable, Subject } from "rxjs" 2 | 3 | /** 4 | * Creates a signal. It's sugar for splitting the Observer and the Observable of a signal. 5 | * 6 | * @param mapper a mapper function, for mapping the arguments of the emitter function into 7 | * the value of the Observable. 8 | * @returns [1, 2] 9 | * 1. The Observable 10 | * 2. The emitter function. 11 | */ 12 | export function createSignal( 13 | mapper: (...args: A) => T, 14 | ): [Observable, (...args: A) => void] 15 | 16 | /** 17 | * Creates a void signal. It's sugar for splitting the Observer and the Observable of a signal. 18 | * 19 | * @returns [1, 2] 20 | * 1. The Observable 21 | * 2. The emitter function. 22 | */ 23 | export function createSignal(): [Observable, () => void] 24 | 25 | /** 26 | * Creates a signal. It's sugar for splitting the Observer and the Observable of a signal. 27 | * 28 | * @returns [1, 2] 29 | * 1. The Observable 30 | * 2. The emitter function. 31 | */ 32 | export function createSignal(): [Observable, (payload: T) => void] 33 | 34 | export function createSignal( 35 | mapper: (...args: A) => T = identity as any, 36 | ): [Observable, (...args: A) => void] { 37 | const subject = new Subject() 38 | return [subject.asObservable(), (...args: A) => subject.next(mapper(...args))] 39 | } 40 | -------------------------------------------------------------------------------- /packages/utils/src/index.tsx: -------------------------------------------------------------------------------- 1 | export { combineKeys, MapWithChanges } from "./combineKeys" 2 | export { createSignal } from "./createSignal" 3 | export { createKeyedSignal } from "./createKeyedSignal" 4 | export { mergeWithKey } from "./mergeWithKey" 5 | export { partitionByKey, KeyChanges } from "./partitionByKey" 6 | export { toKeySet } from "./toKeySet" 7 | export { suspend } from "./suspend" 8 | export { suspended } from "./suspended" 9 | export { switchMapSuspended } from "./switchMapSuspended" 10 | export { selfDependent, selfDependant } from "./selfDependent" 11 | export { contextBinder } from "./contextBinder" 12 | export { createListener } from "./createListener" 13 | -------------------------------------------------------------------------------- /packages/utils/src/internal-utils.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from "rxjs" 2 | 3 | export const defaultStart = 4 | (value: D) => 5 | (source$: Observable) => 6 | new Observable((observer) => { 7 | let emitted = false 8 | const subscription = source$.subscribe( 9 | (x) => { 10 | emitted = true 11 | observer.next(x) 12 | }, 13 | (e) => { 14 | observer.error(e) 15 | }, 16 | () => { 17 | observer.complete() 18 | }, 19 | ) 20 | 21 | if (!emitted) { 22 | observer.next(value) 23 | } 24 | 25 | return subscription 26 | }) 27 | -------------------------------------------------------------------------------- /packages/utils/src/mergeWithKey.spec.ts: -------------------------------------------------------------------------------- 1 | import { map } from "rxjs/operators" 2 | import { TestScheduler } from "rxjs/testing" 3 | import { mergeWithKey } from "./" 4 | import { describe, expect, it } from "vitest" 5 | 6 | const scheduler = () => 7 | new TestScheduler((actual, expected) => { 8 | expect(actual).toEqual(expected) 9 | }) 10 | 11 | describe("mergeWithKey", () => { 12 | it("emits the key of the stream that emitted the value", () => { 13 | scheduler().run(({ expectObservable, cold }) => { 14 | const sourceA = cold("a---b---|") 15 | const sourceB = cold("-1--2----3|") 16 | const expected = " mn--(op)-q|" 17 | 18 | const result = mergeWithKey({ 19 | strings: sourceA, 20 | numbers: sourceB, 21 | }).pipe(map(({ type, payload }) => `${type}:${payload}`)) 22 | 23 | expectObservable(result).toBe(expected, { 24 | m: "strings:a", 25 | n: "numbers:1", 26 | o: "strings:b", 27 | p: "numbers:2", 28 | q: "numbers:3", 29 | }) 30 | }) 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /packages/utils/src/mergeWithKey.ts: -------------------------------------------------------------------------------- 1 | import { merge, Observable, ObservableInput, from, SchedulerLike } from "rxjs" 2 | import { map } from "rxjs/operators" 3 | 4 | /** 5 | * Emits the values from all the streams of the provided object, in a result 6 | * which provides the key of the stream of that emission. 7 | * 8 | * @param input object of streams 9 | */ 10 | export const mergeWithKey: < 11 | O extends { [P in keyof any]: ObservableInput }, 12 | OT extends { 13 | [K in keyof O]: O[K] extends ObservableInput 14 | ? { type: K; payload: V } 15 | : unknown 16 | }, 17 | >( 18 | x: O, 19 | concurrent?: number, 20 | scheduler?: SchedulerLike, 21 | ) => Observable = (input, ...optionalArgs) => 22 | merge( 23 | ...(Object.entries(input) 24 | .map( 25 | ([type, stream]) => 26 | from(stream).pipe( 27 | map((payload) => ({ type, payload }) as any), 28 | ) as any, 29 | ) 30 | .concat(optionalArgs) as any[]), 31 | ) 32 | -------------------------------------------------------------------------------- /packages/utils/src/partitionByKey.test.ts: -------------------------------------------------------------------------------- 1 | import { concat, EMPTY, from, NEVER, Observable, of, Subject } from "rxjs" 2 | import { catchError, map, switchMap, take } from "rxjs/operators" 3 | import { TestScheduler } from "rxjs/testing" 4 | import { beforeEach, describe, expect, it, vi } from "vitest" 5 | import { combineKeys, KeyChanges, partitionByKey } from "./" 6 | import "expose-gc" 7 | 8 | const scheduler = () => 9 | new TestScheduler((actual, expected) => { 10 | expect(actual).toEqual(expected) 11 | }) 12 | 13 | describe("partitionByKey", () => { 14 | describe("behaviour", () => { 15 | it("groups observables by using the key function", () => { 16 | scheduler().run(({ expectObservable, cold }) => { 17 | const source = cold("-12-3456-") 18 | const expectOdd = " -1--3-5--" 19 | const expectEven = " --2--4-6-" 20 | 21 | const [getInstance$] = partitionByKey(source, (v) => Number(v) % 2) 22 | 23 | expectObservable(getInstance$(0)).toBe(expectEven) 24 | expectObservable(getInstance$(1)).toBe(expectOdd) 25 | }) 26 | }) 27 | 28 | it("unsubscribes from all streams when refcount reaches 0", () => { 29 | let innerSubs = 0 30 | const inner = new Observable(() => { 31 | innerSubs++ 32 | return () => { 33 | innerSubs-- 34 | } 35 | }) 36 | 37 | const sourceSubject = new Subject() 38 | let sourceSubs = 0 39 | const source = new Observable((obs) => { 40 | sourceSubs++ 41 | sourceSubject.subscribe(obs) 42 | return () => { 43 | sourceSubs-- 44 | } 45 | }) 46 | 47 | const [getObs] = partitionByKey( 48 | source, 49 | (v) => v, 50 | () => inner, 51 | ) 52 | const observable = getObs(1) 53 | 54 | expect(sourceSubs).toBe(0) 55 | expect(innerSubs).toBe(0) 56 | 57 | const sub1 = observable.subscribe() 58 | 59 | expect(sourceSubs).toBe(1) 60 | expect(innerSubs).toBe(0) 61 | 62 | sourceSubject.next(1) 63 | 64 | expect(sourceSubs).toBe(1) 65 | expect(innerSubs).toBe(1) 66 | 67 | const sub2 = observable.subscribe() 68 | 69 | expect(sourceSubs).toBe(1) 70 | expect(innerSubs).toBe(1) 71 | 72 | sub1.unsubscribe() 73 | 74 | expect(sourceSubs).toBe(1) 75 | expect(innerSubs).toBe(1) 76 | 77 | sub2.unsubscribe() 78 | 79 | expect(sourceSubs).toBe(0) 80 | expect(innerSubs).toBe(0) 81 | }) 82 | 83 | it("emits a complete on the inner observable when the source completes", () => { 84 | scheduler().run(({ expectObservable, cold }) => { 85 | const source = cold("-ab-a-|") 86 | const expectA = " -a--a-(c|)" 87 | const expectB = " --b---(c|)" 88 | 89 | const [getInstance$] = partitionByKey( 90 | source, 91 | (v) => v, 92 | (v$) => concat(v$, ["c"]), 93 | ) 94 | 95 | expectObservable(getInstance$("a")).toBe(expectA) 96 | expectObservable(getInstance$("b")).toBe(expectB) 97 | }) 98 | }) 99 | 100 | it("emits the error on the inner observable when the source errors", () => { 101 | scheduler().run(({ expectObservable, cold }) => { 102 | const source = cold("-ab-a-#") 103 | const expectA = " -a--a-(e|)" 104 | const expectB = " --b---(e|)" 105 | 106 | const [getInstance$] = partitionByKey( 107 | source, 108 | (v) => v, 109 | (v$) => v$.pipe(catchError(() => of("e"))), 110 | ) 111 | 112 | expectObservable(getInstance$("a")).toBe(expectA) 113 | expectObservable(getInstance$("b")).toBe(expectB) 114 | }) 115 | }) 116 | 117 | it("handles an empty Observable", () => { 118 | scheduler().run(({ expectSubscriptions, expectObservable, cold }) => { 119 | const e1 = cold(" |") 120 | const e1subs = " (^!)" 121 | const expectObs = "|" 122 | const expectKey = "|" 123 | 124 | const [getObs, keys$] = partitionByKey( 125 | e1, 126 | (v) => v, 127 | (v$) => v$, 128 | ) 129 | 130 | expectObservable(getObs("")).toBe(expectObs) 131 | expectSubscriptions(e1.subscriptions).toBe(e1subs) 132 | expectObservable(keys$).toBe(expectKey) 133 | }) 134 | }) 135 | 136 | it("handles a never Observable", () => { 137 | scheduler().run(({ expectSubscriptions, expectObservable, cold }) => { 138 | const e1 = cold(" --") 139 | const e1subs = " ^-" 140 | const expectObs = "--" 141 | const expectKey = "--" 142 | 143 | const [getObs, keys$] = partitionByKey( 144 | e1, 145 | (v) => v, 146 | (v$) => v$, 147 | ) 148 | 149 | expectObservable(getObs("")).toBe(expectObs) 150 | expectSubscriptions(e1.subscriptions).toBe(e1subs) 151 | expectObservable(keys$).toBe(expectKey) 152 | }) 153 | }) 154 | 155 | it("handles a just-throw Observable", () => { 156 | scheduler().run(({ expectSubscriptions, expectObservable, cold }) => { 157 | const e1 = cold(" #") 158 | const e1subs = " (^!)" 159 | const expectObs = "#" 160 | const expectKey = "#" 161 | 162 | const [getObs, keys$] = partitionByKey( 163 | e1, 164 | (v) => v, 165 | (v$) => v$, 166 | ) 167 | 168 | expectObservable(getObs("")).toBe(expectObs) 169 | expectSubscriptions(e1.subscriptions).toBe(e1subs) 170 | expectObservable(keys$).toBe(expectKey) 171 | }) 172 | }) 173 | 174 | it("handles synchronous values", () => { 175 | scheduler().run(({ expectObservable }) => { 176 | const e1 = from(["1", "2", "3", "4", "5"]) 177 | const expectOdd = " (135|)" 178 | const expectEven = "(24|)" 179 | const expectKeys = "(wxyz|)" 180 | const [getObs, keys$] = partitionByKey( 181 | e1, 182 | (v) => Number(v) % 2, 183 | (v$) => v$, 184 | ) 185 | expectObservable(deltasToPOJO(keys$)).toBe(expectKeys, { 186 | w: { 187 | type: "add", 188 | keys: [1], 189 | }, 190 | x: { 191 | type: "add", 192 | keys: [0], 193 | }, 194 | y: { 195 | type: "remove", 196 | keys: [1], 197 | }, 198 | z: { 199 | type: "remove", 200 | keys: [0], 201 | }, 202 | }) 203 | expectObservable(getObs(0)).toBe(expectEven) 204 | expectObservable(getObs(1)).toBe(expectOdd) 205 | }) 206 | }) 207 | }) 208 | 209 | describe("activeKeys$", () => { 210 | it("emits deltas when keys are added", () => { 211 | scheduler().run(({ expectObservable, cold }) => { 212 | const source = cold("-ab-a-cd---") 213 | const expectedStr = "-fg---hi---" 214 | const [, result] = partitionByKey( 215 | source, 216 | (v) => v, 217 | () => NEVER, 218 | ) 219 | 220 | expectObservable(deltasToPOJO(result)).toBe(expectedStr, { 221 | f: { 222 | type: "add", 223 | keys: ["a"], 224 | }, 225 | g: { 226 | type: "add", 227 | keys: ["b"], 228 | }, 229 | h: { 230 | type: "add", 231 | keys: ["c"], 232 | }, 233 | i: { 234 | type: "add", 235 | keys: ["d"], 236 | }, 237 | }) 238 | }) 239 | }) 240 | 241 | it("removes a key when its inner stream completes", () => { 242 | scheduler().run(({ expectObservable, cold }) => { 243 | const source = cold("-ab---c--") 244 | const a = cold(" --1---2-") 245 | const b = cold(" ---|") 246 | const c = cold(" 1-|") 247 | const expectedStr = "-fg--hi-j" 248 | const innerStreams: Record> = { a, b, c } 249 | const [, result] = partitionByKey( 250 | source, 251 | (v) => v, 252 | (v$) => 253 | v$.pipe( 254 | take(1), 255 | switchMap((v) => innerStreams[v]), 256 | ), 257 | ) 258 | 259 | expectObservable(deltasToPOJO(result)).toBe(expectedStr, { 260 | f: { 261 | type: "add", 262 | keys: ["a"], 263 | }, 264 | g: { 265 | type: "add", 266 | keys: ["b"], 267 | }, 268 | h: { 269 | type: "remove", 270 | keys: ["b"], 271 | }, 272 | i: { 273 | type: "add", 274 | keys: ["c"], 275 | }, 276 | j: { 277 | type: "remove", 278 | keys: ["c"], 279 | }, 280 | }) 281 | }) 282 | }) 283 | 284 | it("emits the changes on a key even if it's removed synchronously", () => { 285 | scheduler().run(({ expectObservable, cold }) => { 286 | const source = cold("-ae----s----") 287 | const expectedStr = "-f(gh)-(ij)-" 288 | const [, result] = partitionByKey( 289 | source, 290 | (v) => v, 291 | (_, key) => (key === "e" ? EMPTY : key === "s" ? of(1) : NEVER), 292 | ) 293 | 294 | expectObservable(deltasToPOJO(result)).toBe(expectedStr, { 295 | f: { 296 | type: "add", 297 | keys: ["a"], 298 | }, 299 | g: { 300 | type: "add", 301 | keys: ["e"], 302 | }, 303 | h: { 304 | type: "remove", 305 | keys: ["e"], 306 | }, 307 | i: { 308 | type: "add", 309 | keys: ["s"], 310 | }, 311 | j: { 312 | type: "remove", 313 | keys: ["s"], 314 | }, 315 | }) 316 | }) 317 | }) 318 | 319 | it("emits all the existing keys when subscribing late", () => { 320 | scheduler().run(({ expectObservable, cold }) => { 321 | const source = cold("-abe-a-cd---") 322 | const sub1 = " ^--------" 323 | const sub2 = " ----^----" 324 | const expectedStr = "----f--gh---" 325 | const [, result] = partitionByKey( 326 | source, 327 | (v) => v, 328 | (_, key) => (key === "e" ? EMPTY : NEVER), 329 | ) 330 | 331 | expectObservable(deltasToPOJO(result), sub1) 332 | expectObservable(deltasToPOJO(result), sub2).toBe(expectedStr, { 333 | f: { 334 | type: "add", 335 | keys: ["a", "b"], 336 | }, 337 | g: { 338 | type: "add", 339 | keys: ["c"], 340 | }, 341 | h: { 342 | type: "add", 343 | keys: ["d"], 344 | }, 345 | }) 346 | }) 347 | }) 348 | 349 | it("keeps the existing keys alive when the source completes", () => { 350 | scheduler().run(({ expectObservable, cold }) => { 351 | const source = cold("-ab-|") 352 | const a = cold(" --1---2-|") 353 | const b = cold(" ---|") 354 | const expectedStr = "-fg--h---(i|)" 355 | const innerStreams: Record> = { a, b } 356 | const [, result] = partitionByKey( 357 | source, 358 | (v) => v, 359 | (v$) => 360 | v$.pipe( 361 | take(1), 362 | switchMap((v) => innerStreams[v]), 363 | ), 364 | ) 365 | 366 | expectObservable(deltasToPOJO(result)).toBe(expectedStr, { 367 | f: { 368 | type: "add", 369 | keys: ["a"], 370 | }, 371 | g: { 372 | type: "add", 373 | keys: ["b"], 374 | }, 375 | h: { 376 | type: "remove", 377 | keys: ["b"], 378 | }, 379 | i: { 380 | type: "remove", 381 | keys: ["a"], 382 | }, 383 | }) 384 | }) 385 | }) 386 | 387 | it("completes when no key is alive and the source completes", () => { 388 | scheduler().run(({ expectObservable, cold }) => { 389 | const source = cold("-ab---|") 390 | const a = cold(" --1|") 391 | const b = cold(" ---|") 392 | const expectedStr = "-fg-hi|" 393 | const innerStreams: Record> = { a, b } 394 | const [, result] = partitionByKey( 395 | source, 396 | (v) => v, 397 | (v$) => 398 | v$.pipe( 399 | take(1), 400 | switchMap((v) => innerStreams[v]), 401 | ), 402 | ) 403 | 404 | expectObservable(deltasToPOJO(result)).toBe(expectedStr, { 405 | f: { 406 | type: "add", 407 | keys: ["a"], 408 | }, 409 | g: { 410 | type: "add", 411 | keys: ["b"], 412 | }, 413 | h: { 414 | type: "remove", 415 | keys: ["a"], 416 | }, 417 | i: { 418 | type: "remove", 419 | keys: ["b"], 420 | }, 421 | }) 422 | }) 423 | }) 424 | 425 | it("errors when the source emits an error and no group is active", () => { 426 | scheduler().run(({ expectObservable, cold }) => { 427 | const source = cold("-ab--#") 428 | const a = cold(" --1|") 429 | const b = cold(" -|") 430 | const expectedStr = "-fghi#" 431 | const innerStreams: Record> = { a, b } 432 | const [, result] = partitionByKey( 433 | source, 434 | (v) => v, 435 | (v$) => 436 | v$.pipe( 437 | take(1), 438 | switchMap((v) => innerStreams[v]), 439 | ), 440 | ) 441 | 442 | expectObservable(deltasToPOJO(result)).toBe(expectedStr, { 443 | f: { 444 | type: "add", 445 | keys: ["a"], 446 | }, 447 | g: { 448 | type: "add", 449 | keys: ["b"], 450 | }, 451 | h: { 452 | type: "remove", 453 | keys: ["b"], 454 | }, 455 | i: { 456 | type: "remove", 457 | keys: ["a"], 458 | }, 459 | }) 460 | }) 461 | }) 462 | 463 | it("doesn't error when the source errors and its inner streams stop the error", () => { 464 | scheduler().run(({ expectObservable, cold }) => { 465 | const source = cold("-ab--#") 466 | const a = cold(" --1--2--3|") 467 | const b = cold(" ----|") 468 | const expectedStr = "-fg---h---(i|)" 469 | const innerStreams: Record> = { a, b } 470 | const [, result] = partitionByKey( 471 | source, 472 | (v) => v, 473 | (v$) => 474 | v$.pipe( 475 | take(1), 476 | switchMap((v) => innerStreams[v]), 477 | ), 478 | ) 479 | 480 | expectObservable(deltasToPOJO(result)).toBe(expectedStr, { 481 | f: { 482 | type: "add", 483 | keys: ["a"], 484 | }, 485 | g: { 486 | type: "add", 487 | keys: ["b"], 488 | }, 489 | h: { 490 | type: "remove", 491 | keys: ["b"], 492 | }, 493 | i: { 494 | type: "remove", 495 | keys: ["a"], 496 | }, 497 | }) 498 | }) 499 | }) 500 | 501 | it("errors when one of its inner stream emits an error", () => { 502 | scheduler().run(({ expectObservable, cold }) => { 503 | const source = cold("-ab-----") 504 | const a = cold(" --1-#") 505 | const b = cold(" ------") 506 | const expectedStr = "-fg--#" 507 | const innerStreams: Record> = { a, b } 508 | const [, result] = partitionByKey( 509 | source, 510 | (v) => v, 511 | (_, v) => innerStreams[v], 512 | ) 513 | 514 | expectObservable(deltasToPOJO(result)).toBe(expectedStr, { 515 | f: { 516 | type: "add", 517 | keys: ["a"], 518 | }, 519 | g: { 520 | type: "add", 521 | keys: ["b"], 522 | }, 523 | }) 524 | }) 525 | }) 526 | }) 527 | 528 | describe("getInstance$", () => { 529 | it("returns the values for the selected key", () => { 530 | scheduler().run(({ expectObservable, cold }) => { 531 | const source = cold("-ab---c--") 532 | const a = cold(" --1---2-") 533 | const b = cold(" ---|") 534 | const c = cold(" 1-|") 535 | const expectA = " ---1---2--" 536 | const expectB = " -----|" 537 | const expectC = " ------1-|" 538 | 539 | const innerStreams: Record> = { a, b, c } 540 | const [getInstance$] = partitionByKey( 541 | source, 542 | (v) => v, 543 | (v$) => 544 | v$.pipe( 545 | take(1), 546 | switchMap((v) => innerStreams[v]), 547 | ), 548 | ) 549 | 550 | expectObservable(getInstance$("a")).toBe(expectA) 551 | expectObservable(getInstance$("b")).toBe(expectB) 552 | expectObservable(getInstance$("c")).toBe(expectC) 553 | }) 554 | }) 555 | 556 | it("replays the latest value for each key", () => { 557 | const source$ = new Subject() 558 | const inner$ = new Subject() 559 | const [getInstance$] = partitionByKey( 560 | source$, 561 | (v) => v, 562 | () => inner$, 563 | ) 564 | 565 | const next = vi.fn() 566 | getInstance$("a").subscribe(next) 567 | 568 | source$.next("a") 569 | expect(next).not.toHaveBeenCalled() 570 | 571 | inner$.next(1) 572 | expect(next).toHaveBeenCalledTimes(1) 573 | expect(next).toHaveBeenCalledWith(1) 574 | 575 | const lateNext = vi.fn() 576 | getInstance$("a").subscribe(lateNext) 577 | expect(lateNext).toHaveBeenCalledTimes(1) 578 | expect(lateNext).toHaveBeenCalledWith(1) 579 | }) 580 | 581 | it("lets the projection function handle completions", () => { 582 | scheduler().run(({ expectObservable, cold }) => { 583 | const source = cold("-ab-a-|") 584 | const concatenated = cold("123|") 585 | const expectA = " -a--a-123|" 586 | const expectB = " --b---123|" 587 | 588 | const [getInstance$] = partitionByKey( 589 | source, 590 | (v) => v, 591 | (v$) => concat(v$, concatenated), 592 | ) 593 | 594 | expectObservable(getInstance$("a")).toBe(expectA) 595 | expectObservable(getInstance$("b")).toBe(expectB) 596 | }) 597 | }) 598 | 599 | it("lets the projection function catch source errors", () => { 600 | scheduler().run(({ expectObservable, cold }) => { 601 | const source = cold("-ab-a-#") 602 | const expectA = " -a--a-(e|)" 603 | const expectB = " --b---(e|)" 604 | 605 | const [getInstance$] = partitionByKey( 606 | source, 607 | (v) => v, 608 | (v$) => v$.pipe(catchError(() => of("e"))), 609 | ) 610 | 611 | expectObservable(getInstance$("a")).toBe(expectA) 612 | expectObservable(getInstance$("b")).toBe(expectB) 613 | }) 614 | }) 615 | 616 | it("synchronously emits when the group observable notifies of a new GroupedObservable", () => { 617 | const subject = new Subject() 618 | const [getInner$, keys$] = partitionByKey(subject, (x) => x, take(1)) 619 | 620 | const key = 8 621 | let receivedValue = 0 622 | let deleted: number[] = [] 623 | let done = false 624 | let order: string[] = [] 625 | keys$.subscribe((keys) => { 626 | if (keys.type === "add") { 627 | order.push("outer add") 628 | getInner$([...keys.keys][0]).subscribe({ 629 | next: (x) => { 630 | receivedValue = x 631 | order.push("inner next") 632 | }, 633 | complete: () => { 634 | order.push("inner complete") 635 | done = true 636 | }, 637 | }) 638 | } else { 639 | order.push("outer delete") 640 | deleted = [...keys.keys] 641 | } 642 | }) 643 | 644 | subject.next(key) 645 | 646 | expect(receivedValue).toBe(key) 647 | expect(done).toBe(true) 648 | expect(deleted).toEqual([key]) 649 | expect(order).toEqual([ 650 | "outer add", 651 | "inner next", 652 | "outer delete", 653 | "inner complete", 654 | ]) 655 | }) 656 | }) 657 | 658 | describe("performance", () => { 659 | beforeEach(() => { 660 | ;(global as any).gc() 661 | }) 662 | it("has an acceptable performance when it synchronously receives a gust of new keys", () => { 663 | const array = new Array(15_000).fill(0).map((_, i) => i) 664 | 665 | const [, keys$] = partitionByKey(from(array), (v) => v) 666 | 667 | const start = performance.now() 668 | keys$.subscribe() 669 | const result = performance.now() - start 670 | expect(result).toBeLessThan(800) 671 | }) 672 | 673 | it("has an acceptable performance when it synchronously receives a gust of new keys and subscriptions are created on every inner observable", () => { 674 | const array = new Array(7_500).fill(0).map((_, i) => i) 675 | 676 | const [getInner$, keys$] = partitionByKey(from(array), (v) => v) 677 | const result$ = combineKeys(keys$, getInner$) 678 | 679 | const start = performance.now() 680 | result$.subscribe() 681 | const result = performance.now() - start 682 | expect(result).toBeLessThan(800) 683 | }) 684 | }) 685 | }) 686 | 687 | function deltasToPOJO(observable: Observable>) { 688 | return observable.pipe( 689 | map((change) => ({ 690 | type: change.type, 691 | keys: Array.from(change.keys), 692 | })), 693 | ) 694 | } 695 | -------------------------------------------------------------------------------- /packages/utils/src/partitionByKey.ts: -------------------------------------------------------------------------------- 1 | import { shareLatest } from "@react-rxjs/core" 2 | import { 3 | GroupedObservable, 4 | identity, 5 | noop, 6 | Observable, 7 | Subject, 8 | Subscription, 9 | } from "rxjs" 10 | import { map } from "rxjs/operators" 11 | 12 | export interface KeyChanges { 13 | type: "add" | "remove" 14 | keys: Iterable 15 | } 16 | 17 | /** 18 | * Groups the elements from the source stream by using `keySelector`, returning 19 | * a stream of the active keys, and a function to get the stream of a specific group 20 | * 21 | * @param stream Input stream 22 | * @param keySelector Function that specifies the key for each element in `stream` 23 | * @param streamSelector Function to apply to each resulting group 24 | * @returns [1, 2] 25 | * 1. A function that accepts a key and returns the stream for the group of that key. 26 | * 2. A stream of KeyChanges, an object that describes what keys have been added or deleted. 27 | */ 28 | export function partitionByKey( 29 | stream: Observable, 30 | keySelector: (value: T) => K, 31 | streamSelector: (grouped: Observable, key: K) => Observable, 32 | ): [(key: K) => GroupedObservable, Observable>] 33 | 34 | /** 35 | * Groups the elements from the source stream by using `keySelector`, returning 36 | * a stream of the active keys, and a function to get the stream of a specific group 37 | * 38 | * @param stream Input stream 39 | * @param keySelector Function that specifies the key for each element in `stream` 40 | * @returns [1, 2] 41 | * 1. A function that accepts a key and returns the stream for the group of that key. 42 | * 2. A stream of KeyChanges, an object that describes what keys have been added or deleted. 43 | */ 44 | export function partitionByKey( 45 | stream: Observable, 46 | keySelector: (value: T) => K, 47 | ): [(key: K) => GroupedObservable, Observable>] 48 | 49 | export function partitionByKey( 50 | stream: Observable, 51 | keySelector: (value: T) => K, 52 | streamSelector?: (grouped: Observable, key: K) => Observable, 53 | ): [(key: K) => GroupedObservable, Observable>] { 54 | const groupedObservables$ = new Observable<{ 55 | groups: Map> 56 | changes: KeyChanges 57 | }>((subscriber) => { 58 | const groups: Map> = new Map() 59 | 60 | let sourceCompleted = false 61 | const finalize = 62 | (type: "error" | "complete") => 63 | (...args: any[]) => { 64 | sourceCompleted = true 65 | if (groups.size) { 66 | groups.forEach((g) => (g.source[type] as any)(...args)) 67 | } else { 68 | subscriber[type](...args) 69 | } 70 | } 71 | 72 | const sub = stream.subscribe( 73 | (x) => { 74 | const key = keySelector(x) 75 | if (groups.has(key)) return groups.get(key)!.source.next(x) 76 | 77 | let pendingFirstAdd = true 78 | const emitFirstAdd = () => { 79 | if (pendingFirstAdd) { 80 | pendingFirstAdd = false 81 | subscriber.next({ 82 | groups, 83 | changes: { 84 | type: "add", 85 | keys: [key], 86 | }, 87 | }) 88 | } 89 | } 90 | 91 | const subject = new Subject() 92 | let pendingFirstVal = true 93 | const emitFirstValue = () => { 94 | if (pendingFirstVal) { 95 | pendingFirstVal = false 96 | subject.next(x) 97 | } 98 | } 99 | 100 | const shared$ = shareLatest()( 101 | (streamSelector || identity)(subject, key), 102 | ) 103 | const res = new Observable((observer) => { 104 | incRefcount() 105 | const subscription = shared$.subscribe(observer) 106 | subscription.add(decRefcount) 107 | emitFirstValue() 108 | return subscription 109 | }) as any as GroupedObservable 110 | ;(res as any).key = key 111 | 112 | const innerGroup: InnerGroup = { 113 | source: subject, 114 | observable: res, 115 | subscription: new Subscription(), 116 | } 117 | groups.set(key, innerGroup) 118 | 119 | innerGroup.subscription = shared$.subscribe( 120 | noop, 121 | (e) => subscriber.error(e), 122 | () => { 123 | groups.delete(key) 124 | emitFirstAdd() 125 | subscriber.next({ 126 | groups, 127 | changes: { 128 | type: "remove", 129 | keys: [key], 130 | }, 131 | }) 132 | 133 | if (groups.size === 0 && sourceCompleted) { 134 | subscriber.complete() 135 | } 136 | }, 137 | ) 138 | emitFirstAdd() 139 | emitFirstValue() 140 | }, 141 | finalize("error"), 142 | finalize("complete"), 143 | ) 144 | 145 | return () => { 146 | sub.unsubscribe() 147 | groups.forEach((g) => { 148 | g.source.unsubscribe() 149 | g.subscription.unsubscribe() 150 | }) 151 | } 152 | }).pipe(shareLatest()) 153 | 154 | let refCount = 0 155 | let sub: Subscription | undefined 156 | function incRefcount() { 157 | refCount++ 158 | if (refCount === 1) { 159 | sub = groupedObservables$.subscribe() 160 | } 161 | } 162 | function decRefcount() { 163 | refCount-- 164 | if (refCount === 0) { 165 | sub?.unsubscribe() 166 | } 167 | } 168 | 169 | return [ 170 | (key: K) => 171 | getGroupedObservable( 172 | groupedObservables$.pipe(map(({ groups }) => groups)), 173 | key, 174 | ), 175 | groupedObservables$.pipe( 176 | map((m, i): KeyChanges => { 177 | if (i === 0) { 178 | // Replay all the previously added keys 179 | return { 180 | type: "add", 181 | keys: m.groups.keys(), 182 | } 183 | } 184 | return m.changes 185 | }), 186 | ), 187 | ] 188 | } 189 | 190 | interface InnerGroup { 191 | source: Subject 192 | observable: GroupedObservable 193 | subscription: Subscription 194 | } 195 | 196 | const getGroupedObservable = ( 197 | source$: Observable>>, 198 | key: K, 199 | ) => { 200 | const result = new Observable((observer) => { 201 | let innerSub: Subscription | undefined 202 | let outerSub: Subscription | undefined 203 | let foundSynchronously = false 204 | outerSub = source$.subscribe( 205 | (n) => { 206 | const innerGroup = n.get(key) 207 | if (innerGroup && !innerSub) { 208 | innerSub = innerGroup.observable.subscribe(observer) 209 | outerSub?.unsubscribe() 210 | foundSynchronously = true 211 | } 212 | }, 213 | (e) => { 214 | observer.error(e) 215 | }, 216 | () => { 217 | observer.complete() 218 | }, 219 | ) 220 | if (foundSynchronously) { 221 | outerSub.unsubscribe() 222 | outerSub = undefined 223 | } 224 | 225 | return () => { 226 | innerSub?.unsubscribe() 227 | outerSub?.unsubscribe() 228 | } 229 | }) as GroupedObservable 230 | ;(result as any).key = key 231 | return result 232 | } 233 | -------------------------------------------------------------------------------- /packages/utils/src/selfDependent.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | map, 3 | withLatestFrom, 4 | pluck, 5 | share, 6 | takeWhile, 7 | switchMapTo, 8 | delay, 9 | startWith, 10 | } from "rxjs/operators" 11 | import { TestScheduler } from "rxjs/testing" 12 | import { selfDependent } from "." 13 | import { merge, Observable, defer, of } from "rxjs" 14 | import { describe, expect, it } from "vitest" 15 | 16 | const scheduler = () => 17 | new TestScheduler((actual, expected) => { 18 | expect(actual).toEqual(expected) 19 | }) 20 | 21 | const inc = (x: number) => x + 1 22 | describe("selfDependent", () => { 23 | it("emits the key of the stream that emitted the value", () => { 24 | scheduler().run(({ expectObservable, expectSubscriptions, cold }) => { 25 | let source: Observable 26 | 27 | const clicks$ = defer(() => source) 28 | const [_resetableCounter$, connect] = selfDependent() 29 | const inc$ = clicks$.pipe( 30 | withLatestFrom(_resetableCounter$), 31 | pluck("1"), 32 | map(inc), 33 | share(), 34 | ) 35 | 36 | const delayedZero$ = of(0).pipe(delay(2)) 37 | const reset$ = inc$.pipe(switchMapTo(delayedZero$)) 38 | 39 | const resetableCounter$ = merge(inc$, reset$, of(0)).pipe( 40 | connect(), 41 | takeWhile((x) => x < 4, true), 42 | ) 43 | 44 | source = cold(" -***---**---*****--") 45 | const sourceSub = "^--------------! " 46 | const expected = " abcd-a-bc-a-bcd(e|)" 47 | 48 | expectObservable(resetableCounter$).toBe(expected, { 49 | a: 0, 50 | b: 1, 51 | c: 2, 52 | d: 3, 53 | e: 4, 54 | }) 55 | expectSubscriptions((source as any).subscriptions).toBe(sourceSub) 56 | }) 57 | }) 58 | 59 | it("works after unsubscription and re-subscription", () => { 60 | scheduler().run(({ expectObservable, cold }) => { 61 | const source = cold("abcde") 62 | const sourceSub1 = " ^--!" 63 | const expected1 = " abc" 64 | const sourceSub2 = " -----^---!" 65 | const expected2 = " -----abcd" 66 | 67 | const [lastValue$, connect] = selfDependent() 68 | const result$ = source.pipe( 69 | withLatestFrom(lastValue$.pipe(startWith(""))), 70 | map(([v]) => v), 71 | connect(), 72 | ) 73 | 74 | expectObservable(result$, sourceSub1).toBe(expected1) 75 | expectObservable(result$, sourceSub2).toBe(expected2) 76 | }) 77 | }) 78 | 79 | it("works after complete and re-subscription", () => { 80 | scheduler().run(({ expectObservable, cold }) => { 81 | const source = cold("abc|") 82 | const sourceSub1 = " ^---!" 83 | const expected1 = " abc|" 84 | const sourceSub2 = " -----^---!" 85 | const expected2 = " -----abc|" 86 | 87 | const [lastValue$, connect] = selfDependent() 88 | const result$ = source.pipe( 89 | withLatestFrom(lastValue$.pipe(startWith(""))), 90 | map(([v]) => v), 91 | connect(), 92 | ) 93 | 94 | expectObservable(result$, sourceSub1).toBe(expected1) 95 | expectObservable(result$, sourceSub2).toBe(expected2) 96 | }) 97 | }) 98 | 99 | it("works after error and re-subscription", () => { 100 | scheduler().run(({ expectObservable, cold }) => { 101 | const source = cold("abc#") 102 | const sourceSub1 = " ^---!" 103 | const expected1 = " abc#" 104 | const sourceSub2 = " -----^---!" 105 | const expected2 = " -----abc#" 106 | 107 | const [lastValue$, connect] = selfDependent() 108 | const result$ = source.pipe( 109 | withLatestFrom(lastValue$.pipe(startWith(""))), 110 | map(([v]) => v), 111 | connect(), 112 | ) 113 | 114 | expectObservable(result$, sourceSub1).toBe(expected1) 115 | expectObservable(result$, sourceSub2).toBe(expected2) 116 | }) 117 | }) 118 | }) 119 | -------------------------------------------------------------------------------- /packages/utils/src/selfDependent.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Observable, 3 | Subject, 4 | MonoTypeOperatorFunction, 5 | BehaviorSubject, 6 | } from "rxjs" 7 | import { switchAll, tap } from "rxjs/operators" 8 | 9 | /** 10 | * A creation operator that helps at creating observables that have circular 11 | * dependencies 12 | * 13 | * @returns [1, 2] 14 | * 1. The inner subject as an Observable 15 | * 2. A pipable operator that taps into the inner Subject 16 | */ 17 | export const selfDependent = (): [ 18 | Observable, 19 | () => MonoTypeOperatorFunction, 20 | ] => { 21 | const activeSubject: BehaviorSubject> = new BehaviorSubject( 22 | new Subject(), 23 | ) 24 | return [ 25 | activeSubject.pipe(switchAll()), 26 | () => 27 | tap({ 28 | next: (v) => activeSubject.value.next(v), 29 | error: (e) => { 30 | activeSubject.value.error(e) 31 | activeSubject.next(new Subject()) 32 | }, 33 | complete: () => { 34 | activeSubject.value.complete() 35 | activeSubject.next(new Subject()) 36 | }, 37 | }) as MonoTypeOperatorFunction, 38 | ] 39 | } 40 | 41 | /** 42 | * @deprecated renamed to `selfDependent` 43 | */ 44 | export const selfDependant = selfDependent 45 | -------------------------------------------------------------------------------- /packages/utils/src/suspend.test.ts: -------------------------------------------------------------------------------- 1 | import { TestScheduler } from "rxjs/testing" 2 | import { SUSPENSE } from "@react-rxjs/core" 3 | import { of } from "rxjs" 4 | import { suspend } from "./" 5 | import { describe, expect, it } from "vitest" 6 | 7 | const scheduler = () => 8 | new TestScheduler((actual, expected) => { 9 | expect(actual).toEqual(expected) 10 | }) 11 | 12 | describe("operators/suspend", () => { 13 | it("prepends the source stream with SUSPENSE", () => { 14 | scheduler().run(({ expectObservable, cold }) => { 15 | const source = cold("----#") 16 | const expected = " s---#" 17 | 18 | const suspended = suspend(source) 19 | 20 | expectObservable(suspended).toBe(expected, { 21 | s: SUSPENSE, 22 | a: "a", 23 | }) 24 | }) 25 | }) 26 | 27 | it("does not prepend the source stream with SUSPENSE when the source is sync", () => { 28 | scheduler().run(({ expectObservable }) => { 29 | const source = of("a") 30 | const expected = "(a|)" 31 | 32 | const suspended = suspend(source) 33 | 34 | expectObservable(suspended).toBe(expected, { 35 | s: SUSPENSE, 36 | a: "a", 37 | }) 38 | }) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /packages/utils/src/suspend.ts: -------------------------------------------------------------------------------- 1 | import { ObservableInput, from, Observable } from "rxjs" 2 | import { SUSPENSE } from "@react-rxjs/core" 3 | import { defaultStart } from "./internal-utils" 4 | 5 | /** 6 | * A RxJS creation operator that prepends a SUSPENSE on the source observable. 7 | * 8 | * @param source$ Source observable 9 | */ 10 | export const suspend: ( 11 | source$: ObservableInput, 12 | ) => Observable = (source$: ObservableInput) => 13 | defaultStart(SUSPENSE)(from(source$)) as any 14 | -------------------------------------------------------------------------------- /packages/utils/src/suspended.test.ts: -------------------------------------------------------------------------------- 1 | import { TestScheduler } from "rxjs/testing" 2 | import { SUSPENSE } from "@react-rxjs/core" 3 | import { of } from "rxjs" 4 | import { suspended } from "./" 5 | import { describe, expect, it } from "vitest" 6 | 7 | const scheduler = () => 8 | new TestScheduler((actual, expected) => { 9 | expect(actual).toEqual(expected) 10 | }) 11 | 12 | describe("operators/suspended", () => { 13 | it("prepends the stream with SUSPENSE", () => { 14 | scheduler().run(({ expectObservable, cold }) => { 15 | const source = cold("----a") 16 | const expected = " s---a" 17 | 18 | const result$ = source.pipe(suspended()) 19 | 20 | expectObservable(result$).toBe(expected, { 21 | s: SUSPENSE, 22 | a: "a", 23 | }) 24 | }) 25 | }) 26 | 27 | it("does not prepend the source stream with SUSPENSE when the source is sync", () => { 28 | scheduler().run(({ expectObservable }) => { 29 | const source = of("a") 30 | const expected = "(a|)" 31 | 32 | const result$ = source.pipe(suspended()) 33 | 34 | expectObservable(result$).toBe(expected, { 35 | s: SUSPENSE, 36 | a: "a", 37 | }) 38 | }) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /packages/utils/src/suspended.ts: -------------------------------------------------------------------------------- 1 | import { suspend } from "./suspend" 2 | import { OperatorFunction } from "rxjs" 3 | import { SUSPENSE } from "@react-rxjs/core" 4 | 5 | /** 6 | * A RxJS pipeable operator that prepends a SUSPENSE on the source observable. 7 | */ 8 | export const suspended = (): OperatorFunction => 9 | suspend 10 | -------------------------------------------------------------------------------- /packages/utils/src/switchMapSuspended.test.ts: -------------------------------------------------------------------------------- 1 | import { TestScheduler } from "rxjs/testing" 2 | import { SUSPENSE } from "@react-rxjs/core" 3 | import { switchMapSuspended } from "./" 4 | import { of } from "rxjs" 5 | import { describe, expect, it } from "vitest" 6 | 7 | const scheduler = () => 8 | new TestScheduler((actual, expected) => { 9 | expect(actual).toEqual(expected) 10 | }) 11 | 12 | describe("operators/switchMapSuspended", () => { 13 | it("acts like a switchMap, but emitting a SUSPENSE when activating the inner stream", () => { 14 | scheduler().run(({ expectObservable, cold }) => { 15 | const source = cold("-x---") 16 | const inner = cold(" ----a") 17 | const expected = " -s---a" 18 | 19 | const result$ = source.pipe(switchMapSuspended(() => inner)) 20 | 21 | expectObservable(result$).toBe(expected, { 22 | s: SUSPENSE, 23 | a: "a", 24 | }) 25 | }) 26 | }) 27 | 28 | it("emits another SUSPENSE when another inner stream activates", () => { 29 | scheduler().run(({ expectObservable, cold }) => { 30 | const source = cold("-x--x") 31 | const inner = cold(" ----a") 32 | const expected = " -s--s---a" 33 | 34 | const result$ = source.pipe(switchMapSuspended(() => inner)) 35 | 36 | expectObservable(result$).toBe(expected, { 37 | s: SUSPENSE, 38 | a: "a", 39 | }) 40 | }) 41 | }) 42 | 43 | it("does not emits another SUSPENSE when the next inner stream is sync", () => { 44 | scheduler().run(({ expectObservable, cold }) => { 45 | const source = cold("-x--x") 46 | const inner = of("a") 47 | const expected = " -a--a" 48 | 49 | const result$ = source.pipe(switchMapSuspended(() => inner)) 50 | 51 | expectObservable(result$).toBe(expected, { 52 | s: SUSPENSE, 53 | a: "a", 54 | }) 55 | }) 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /packages/utils/src/switchMapSuspended.ts: -------------------------------------------------------------------------------- 1 | import { ObservableInput, OperatorFunction, ObservedValueOf, pipe } from "rxjs" 2 | import { switchMap } from "rxjs/operators" 3 | import { suspend } from "./suspend" 4 | import { SUSPENSE } from "@react-rxjs/core" 5 | 6 | /** 7 | * Same behaviour as rxjs' `switchMap`, but prepending every new event with 8 | * SUSPENSE. 9 | * 10 | * @param fn Projection function 11 | */ 12 | export const switchMapSuspended = >( 13 | project: (value: T, index: number) => O, 14 | ): OperatorFunction | typeof SUSPENSE> => 15 | pipe(switchMap((x, index) => suspend(project(x, index)))) 16 | -------------------------------------------------------------------------------- /packages/utils/src/toKeySet.test.ts: -------------------------------------------------------------------------------- 1 | import { asapScheduler, map, observeOn, of, Subject } from "rxjs" 2 | import { TestScheduler } from "rxjs/testing" 3 | import { KeyChanges, toKeySet } from "./" 4 | import { describe, expect, it } from "vitest" 5 | 6 | const scheduler = () => 7 | new TestScheduler((actual, expected) => { 8 | expect(actual).toEqual(expected) 9 | }) 10 | 11 | describe("toKeySet", () => { 12 | it("transforms key changes to a Set", () => { 13 | scheduler().run(({ expectObservable, cold }) => { 14 | const expectedStr = " xe--f-g--h#" 15 | const source$ = cold>("-a--b-c--d#", { 16 | a: { 17 | type: "add", 18 | keys: ["a", "b"], 19 | }, 20 | b: { 21 | type: "remove", 22 | keys: ["b", "c"], 23 | }, 24 | c: { 25 | type: "add", 26 | keys: ["c"], 27 | }, 28 | d: { 29 | type: "remove", 30 | keys: ["a"], 31 | }, 32 | }) 33 | 34 | const result$ = source$.pipe( 35 | toKeySet(), 36 | map((s) => Array.from(s)), 37 | ) 38 | 39 | expectObservable(result$).toBe(expectedStr, { 40 | x: [], 41 | e: ["a", "b"], 42 | f: ["a"], 43 | g: ["a", "c"], 44 | h: ["c"], 45 | }) 46 | }) 47 | }) 48 | 49 | it("emits synchronously on the first subscribe if it receives a synchronous change", () => { 50 | const emissions: string[][] = [] 51 | of>({ 52 | type: "add", 53 | keys: ["a", "b"], 54 | }) 55 | .pipe(toKeySet()) 56 | .subscribe((next) => emissions.push(Array.from(next))) 57 | 58 | expect(emissions.length).toBe(1) 59 | expect(emissions[0]).toEqual(["a", "b"]) 60 | }) 61 | 62 | it("emits synchronously an empty Set if it doesn't receive a synchronous change", () => { 63 | const emissions: string[][] = [] 64 | of>({ 65 | type: "add", 66 | keys: ["a", "b"], 67 | }) 68 | .pipe(observeOn(asapScheduler), toKeySet()) 69 | .subscribe((next) => emissions.push(Array.from(next))) 70 | 71 | expect(emissions.length).toBe(1) 72 | expect(emissions[0]).toEqual([]) 73 | }) 74 | 75 | it("resets the Set after unsubscribing", () => { 76 | const input$ = new Subject>() 77 | const result$ = input$.pipe(toKeySet()) 78 | 79 | let emissions: string[][] = [] 80 | let sub = result$.subscribe((v) => emissions.push(Array.from(v))) 81 | input$.next({ 82 | type: "add", 83 | keys: ["a"], 84 | }) 85 | expect(emissions.length).toBe(2) // [0] is initial empty [] 86 | expect(emissions[1]).toEqual(["a"]) 87 | sub.unsubscribe() 88 | 89 | emissions = [] 90 | sub = result$.subscribe((v) => emissions.push(Array.from(v))) 91 | input$.next({ 92 | type: "add", 93 | keys: ["b"], 94 | }) 95 | expect(emissions.length).toBe(2) // [0] is initial empty [] 96 | expect(emissions[1]).toEqual(["b"]) 97 | sub.unsubscribe() 98 | }) 99 | }) 100 | -------------------------------------------------------------------------------- /packages/utils/src/toKeySet.ts: -------------------------------------------------------------------------------- 1 | import { Observable, OperatorFunction } from "rxjs" 2 | import { KeyChanges } from "./partitionByKey" 3 | 4 | /** 5 | * Operator function that maps a stream of KeyChanges into a Set that contains 6 | * the active keys. 7 | */ 8 | export function toKeySet(): OperatorFunction, Set> { 9 | return (source$) => 10 | new Observable>((observer) => { 11 | const result = new Set() 12 | let pristine = true 13 | const subscription = source$.subscribe({ 14 | next({ type, keys }) { 15 | const action = type === "add" ? type : "delete" 16 | for (let k of keys) { 17 | result[action](k) 18 | } 19 | observer.next(result) 20 | pristine = false 21 | }, 22 | error(e) { 23 | observer.error(e) 24 | }, 25 | complete() { 26 | observer.complete() 27 | }, 28 | }) 29 | if (pristine) observer.next(result) 30 | return subscription 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /packages/utils/tsconfig-build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["**/*.test.*", "**/test-helpers/*.*"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/utils/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "compilerOptions": { 4 | "target": "esnext", 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./src", 11 | "strict": true, 12 | "noImplicitAny": true, 13 | "strictNullChecks": true, 14 | "strictFunctionTypes": true, 15 | "strictPropertyInitialization": true, 16 | "noImplicitThis": true, 17 | "alwaysStrict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noImplicitReturns": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "moduleResolution": "node", 23 | "baseUrl": "./src", 24 | "paths": { 25 | "*": ["src/*", "node_modules/*"] 26 | }, 27 | "jsx": "react", 28 | "esModuleInterop": true 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config" 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: "jsdom", 6 | globals: true, // needed for testing-library to cleanup between tests 7 | coverage: { 8 | reporter: ["lcov", "html"], 9 | }, 10 | }, 11 | }) 12 | --------------------------------------------------------------------------------