├── .all-contributorsrc ├── .babelrc.js ├── .editorconfig ├── .eslintrc ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── pull-request.yaml │ └── release.yaml ├── .gitignore ├── .gitpod.yml ├── .husky ├── commit-msg └── pre-commit ├── .prettierignore ├── .prettierrc ├── .releaserc.json ├── .vscode ├── extensions.json └── settings.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── examples ├── README.md └── firebase │ ├── .env │ ├── .eslintrc │ ├── .gitignore │ ├── README.md │ ├── index.html │ ├── package.json │ ├── src │ ├── App.tsx │ ├── api │ │ ├── messages.ts │ │ └── users.ts │ ├── components │ │ ├── About │ │ │ ├── About.module.css │ │ │ ├── About.tsx │ │ │ └── index.ts │ │ ├── Avatar │ │ │ ├── Avatar.module.css │ │ │ ├── Avatar.tsx │ │ │ └── index.ts │ │ ├── Box │ │ │ ├── Box.module.css │ │ │ ├── Box.tsx │ │ │ └── index.ts │ │ ├── Chat │ │ │ ├── Chat.tsx │ │ │ └── index.ts │ │ ├── ChatList │ │ │ ├── ChatList.module.css │ │ │ ├── ChatList.tsx │ │ │ └── index.ts │ │ ├── Messages │ │ │ ├── Messages.module.css │ │ │ ├── Messages.tsx │ │ │ └── index.ts │ │ ├── NavBar │ │ │ ├── NavBar.module.css │ │ │ ├── NavBar.tsx │ │ │ └── index.ts │ │ ├── NewMessage │ │ │ ├── NewMessage.module.css │ │ │ ├── NewMessage.tsx │ │ │ └── index.ts │ │ └── User │ │ │ ├── User.module.css │ │ │ ├── User.tsx │ │ │ └── index.ts │ ├── favicon.svg │ ├── fingerprint.ts │ ├── firebase.ts │ ├── hooks │ │ ├── api.ts │ │ └── fingerprint.ts │ ├── index.css │ ├── main.tsx │ ├── routes │ │ ├── about.tsx │ │ ├── chat │ │ │ └── $id.tsx │ │ ├── index.tsx │ │ ├── layout.module.css │ │ └── layout.tsx │ ├── types │ │ ├── infinite-data.ts │ │ ├── message.ts │ │ └── user.ts │ └── vite-env.d.ts │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── __api-mocks__ │ ├── handlers │ │ ├── index.ts │ │ ├── sse.ts │ │ └── utils.ts │ └── server.ts ├── __tests__ │ ├── subscription-storage.spec.ts │ ├── use-infinite-subscription.spec.tsx │ └── use-subscription.spec.tsx ├── helpers │ ├── __tests__ │ │ └── event-source.spec.ts │ └── event-source.ts ├── index.ts ├── subscription-storage.ts ├── use-infinite-subscription.ts ├── use-observable-query-fn.ts └── use-subscription.ts ├── tsconfig.json ├── tsconfig.types.json ├── typedoc.json ├── vite.config.ts └── vitest └── setup-env.ts /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "react-query-subscription", 3 | "projectOwner": "kaciakmaciak", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": false, 11 | "commitConvention": "none", 12 | "contributors": [ 13 | { 14 | "login": "kaciakmaciak", 15 | "name": "Katarina Anton", 16 | "avatar_url": "https://avatars.githubusercontent.com/u/17466633?v=4", 17 | "profile": "https://github.com/kaciakmaciak", 18 | "contributions": [ 19 | "code", 20 | "ideas", 21 | "maintenance", 22 | "test", 23 | "tool", 24 | "infra" 25 | ] 26 | }, 27 | { 28 | "login": "cabljac", 29 | "name": "Jacob Cable", 30 | "avatar_url": "https://avatars.githubusercontent.com/u/32874567?v=4", 31 | "profile": "https://github.com/cabljac", 32 | "contributions": [ 33 | "code", 34 | "ideas" 35 | ] 36 | } 37 | ], 38 | "contributorsPerLine": 7, 39 | "skipCi": true, 40 | "badgeTemplate": "[all-contributors-badge]: https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg?style=round-square 'Number of contributors on All-Contributors'" 41 | } 42 | -------------------------------------------------------------------------------- /.babelrc.js: -------------------------------------------------------------------------------- 1 | const { NODE_ENV, BABEL_ENV } = process.env; 2 | const cjs = NODE_ENV === 'test' || BABEL_ENV === 'commonjs'; 3 | const loose = true; 4 | 5 | module.exports = { 6 | presets: [ 7 | [ 8 | '@babel/env', 9 | { 10 | loose, 11 | modules: cjs ? 'commonjs' : false, 12 | exclude: ['@babel/plugin-transform-regenerator'], 13 | }, 14 | ], 15 | '@babel/preset-typescript', 16 | '@babel/react', 17 | ], 18 | plugins: [ 19 | [ 20 | '@babel/plugin-transform-runtime', 21 | { 22 | useESModules: !cjs, 23 | version: require('./package.json').dependencies[ 24 | '@babel/runtime' 25 | ].replace(/^[^0-9]*/, ''), 26 | }, 27 | ], 28 | ].filter(Boolean), 29 | }; 30 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # @see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.{js,jsx,ts,tsx}] 13 | max_line_length = 80 14 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:react/recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "plugin:jsx-a11y/recommended", 7 | "plugin:prettier/recommended" 8 | ], 9 | "parser": "@typescript-eslint/parser", 10 | "plugins": ["@typescript-eslint", "react-hooks"], 11 | "settings": { 12 | "react": { 13 | "version": "detect" 14 | } 15 | }, 16 | "rules": { 17 | "react-hooks/rules-of-hooks": "error", 18 | "react-hooks/exhaustive-deps": "error", 19 | "@typescript-eslint/explicit-module-boundary-types": "off", 20 | "jsx-a11y/no-autofocus": "off" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | 3 | *.js text eol=lf 4 | *.jsx text eol=lf 5 | *.ts text eol=lf 6 | *.tsx text eol=lf 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Context (please complete the following information):** 27 | 28 | - Node version: [e.g. 14.17] 29 | - React Query version: [e.g. 3.31.0] 30 | - RxJs version: [e.g. 7.4.0] 31 | - OS: [e.g. iOS] 32 | - Browser [e.g. chrome, safari, any] 33 | - Version [e.g. 22] 34 | 35 | **Additional context** 36 | Add any other context about the problem here. 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yaml: -------------------------------------------------------------------------------- 1 | name: Pull Request 2 | on: 3 | pull_request: 4 | 5 | jobs: 6 | lint-test-build: 7 | name: Lint, Test and Build 8 | runs-on: ubuntu-20.04 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions/setup-node@v2 12 | with: 13 | node-version: '18.12' 14 | 15 | - name: Install dependencies 16 | uses: bahmutov/npm-install@v1 17 | with: 18 | install-command: npm ci --ignore-scripts 19 | 20 | - name: Lint 21 | run: npm run lint 22 | 23 | - name: Unit tests 24 | run: npm test -- --coverage 25 | 26 | - name: Upload coverage to Codecov 27 | uses: codecov/codecov-action@v3 28 | with: 29 | token: ${{ secrets.CODECOV_TOKEN }} 30 | directory: coverage 31 | flags: unittests 32 | name: react-query-subscription 33 | 34 | - name: Build package 📦 35 | run: npm run build 36 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - master 6 | - beta 7 | - next 8 | 9 | jobs: 10 | lint-test-build: 11 | name: Lint, Test and Build 12 | runs-on: ubuntu-20.04 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-node@v2 16 | with: 17 | node-version: '18.12' 18 | 19 | - name: Install dependencies 20 | uses: bahmutov/npm-install@v1 21 | with: 22 | install-command: npm ci --ignore-scripts 23 | 24 | - name: Lint 25 | run: npm run lint 26 | 27 | - name: Unit tests 28 | run: npm test -- --coverage 29 | 30 | - name: Upload coverage to Codecov 31 | uses: codecov/codecov-action@v3 32 | with: 33 | token: ${{ secrets.CODECOV_TOKEN }} 34 | directory: coverage 35 | flags: unittests 36 | name: react-query-subscription 37 | 38 | - name: Build package 📦 39 | run: npm run build 40 | 41 | release: 42 | if: github.repository == 'kaciakmaciak/react-query-subscription' 43 | name: 'Publish to NPM' 44 | runs-on: ubuntu-20.04 45 | 46 | needs: [lint-test-build] 47 | 48 | steps: 49 | - uses: actions/checkout@v2 50 | with: 51 | persist-credentials: false 52 | 53 | - uses: actions/setup-node@v2 54 | with: 55 | node-version: '14.17' 56 | 57 | - name: Install dependencies 58 | uses: bahmutov/npm-install@v1 59 | with: 60 | install-command: npm ci --ignore-scripts 61 | 62 | - name: Release 🚀 63 | env: 64 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 65 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 66 | run: npm run release 67 | 68 | - name: Build Docs 🔧 69 | run: npm run docs:pages 70 | 71 | - name: Deploy Docs 🚀 72 | if: github.ref == 'refs/heads/master' 73 | uses: JamesIves/github-pages-deploy-action@4.1.4 74 | with: 75 | GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} 76 | BRANCH: nodev/docs 77 | FOLDER: gh-pages 78 | CLEAN: true 79 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | lib 4 | es 5 | /types 6 | /coverage 7 | 8 | size-plugin.json 9 | stats.json 10 | stats.html 11 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | tasks: 2 | - name: Unit tests 3 | init: | 4 | npm install 5 | gp sync-done boot 6 | command: npm run test:watch 7 | 8 | vscode: 9 | extensions: 10 | - 'esbenp.prettier-vscode' 11 | - 'dbaeumer.vscode-eslint' 12 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | ./node_modules/.bin/commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | ./node_modules/.bin/lint-staged --allow-empty 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | .prettierignore 3 | yarn.lock 4 | yarn-error.log 5 | package-lock.json 6 | dist 7 | lib 8 | es 9 | /types 10 | coverage 11 | pnpm-lock.yaml 12 | /docs 13 | 14 | size-plugin.json 15 | stats.json 16 | stats.html 17 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "printWidth": 80, 4 | "semi": true, 5 | "singleQuote": true, 6 | "quoteProps": "as-needed", 7 | "jsxSingleQuote": false, 8 | "trailingComma": "es5", 9 | "bracketSpacing": true, 10 | "jsxBracketSameLine": false, 11 | "arrowParens": "always" 12 | } 13 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | { "name": "master" }, 4 | { "name": "beta", "channel": "beta", "prerelease": true }, 5 | { "name": "next", "channel": "next" } 6 | ], 7 | "preset": "conventionalcommits", 8 | "presetConfig": { 9 | "types": [ 10 | { "type": "feat", "section": "✨ Features" }, 11 | { "type": "fix", "section": "🐛 Bug Fixes" }, 12 | { "type": "docs", "section": "📚 Documentation", "hidden": true }, 13 | { "type": "style", "section": "🎨 Formatting", "hidden": true }, 14 | { "type": "refactor", "section": "🔨 Refactors", "hidden": true }, 15 | { "type": "perf", "section": "⚡️ Performance" }, 16 | { "type": "test", "section": "🧪 Tests", "hidden": true }, 17 | { "type": "build", "section": "👷 Build System", "hidden": true }, 18 | { 19 | "type": "ci", 20 | "section": "💚 Continuous Integration", 21 | "hidden": true 22 | }, 23 | { "type": "chore", "section": "🍻 Chores", "hidden": true }, 24 | { "type": "revert", "section": "🔙 Reverts" } 25 | ] 26 | }, 27 | "plugins": [ 28 | "@semantic-release/commit-analyzer", 29 | "@semantic-release/release-notes-generator", 30 | "@semantic-release/changelog", 31 | "@semantic-release/npm", 32 | "@semantic-release/git", 33 | "@semantic-release/github" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "EditorConfig.EditorConfig", 5 | "esbenp.prettier-vscode", 6 | "ZixuanChen.vitest-explorer" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.eol": "\n", 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "editor.detectIndentation": true, 5 | "editor.tabSize": 2, 6 | "editor.formatOnSave": true, 7 | "prettier.enable": true, 8 | "prettier.requireConfig": true, 9 | "prettier.useEditorConfig": true, 10 | "eslint.enable": true, 11 | "eslint.format.enable": false, 12 | "eslint.packageManager": "npm", 13 | "eslint.probe": [ 14 | "javascript", 15 | "javascriptreact", 16 | "typescript", 17 | "typescriptreact", 18 | "html", 19 | "markdown" 20 | ], 21 | "vitest.commandLine": "npm run test --" 22 | } 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### [1.8.1](https://github.com/kaciakmaciak/react-query-subscription/compare/v1.8.0...v1.8.1) (2022-10-16) 2 | 3 | 4 | ### 🐛 Bug Fixes 5 | 6 | * fix calling subscription fn multiple times when `subscriptionKey` is not a string ([a7f32f3](https://github.com/kaciakmaciak/react-query-subscription/commit/a7f32f3d1c958dc40c74dec8c8e99ced464e2f80)) 7 | 8 | ## [1.8.0](https://github.com/kaciakmaciak/react-query-subscription/compare/v1.7.0...v1.8.0) (2022-10-08) 9 | 10 | 11 | ### ✨ Features 12 | 13 | * **peer-deps:** support React v18 ([3b3dd5c](https://github.com/kaciakmaciak/react-query-subscription/commit/3b3dd5c32bf1325e238bdea71f1c3f642476b13f)) 14 | 15 | ## [1.7.0](https://github.com/kaciakmaciak/react-query-subscription/compare/v1.6.0...v1.7.0) (2022-10-07) 16 | 17 | 18 | ### ✨ Features 19 | 20 | * **helpers:** deprecate `fromEventSource` and `eventSource$` helpers ([01cf6b9](https://github.com/kaciakmaciak/react-query-subscription/commit/01cf6b9a23bba80811a0d6e20b9bfe40664f0431)) 21 | 22 | ## [1.6.0](https://github.com/kaciakmaciak/react-query-subscription/compare/v1.5.1...v1.6.0) (2022-09-30) 23 | 24 | 25 | ### ✨ Features 26 | 27 | * **useInfiniteSubscription:** add `useInfiniteSubscription` hook ([64a3f94](https://github.com/kaciakmaciak/react-query-subscription/commit/64a3f94b41dda867579abadf8c6aa7fc8d9b34c6)), closes [#55](https://github.com/kaciakmaciak/react-query-subscription/issues/55) 28 | * **useSubscription:** pass `queryKey` to `subscriptionFn` ([7564823](https://github.com/kaciakmaciak/react-query-subscription/commit/75648232ed75b0a74adf71fe9aa2f7445cf60f3a)) 29 | 30 | 31 | ### 🐛 Bug Fixes 32 | 33 | * **useInfiniteSubscription:** fix unsubscribing when previous/next page has been fetched ([a05675b](https://github.com/kaciakmaciak/react-query-subscription/commit/a05675be0fb29b83acef3a803977900504a09c47)) 34 | 35 | ## [1.6.0-beta.2](https://github.com/kaciakmaciak/react-query-subscription/compare/v1.6.0-beta.1...v1.6.0-beta.2) (2022-07-02) 36 | 37 | 38 | ### 🐛 Bug Fixes 39 | 40 | * **useInfiniteSubscription:** fix unsubscribing when previous/next page has been fetched ([fef6b3f](https://github.com/kaciakmaciak/react-query-subscription/commit/fef6b3fd8cb54615fd6b7eb153266b5696f0318b)) 41 | 42 | ## [1.6.0-beta.1](https://github.com/kaciakmaciak/react-query-subscription/compare/v1.5.1...v1.6.0-beta.1) (2022-07-02) 43 | 44 | 45 | ### ✨ Features 46 | 47 | * **useInfiniteSubscription:** add `useInfiniteSubscription` hook ([d1d1da1](https://github.com/kaciakmaciak/react-query-subscription/commit/d1d1da1635559455f850ab136a34edafecba91fd)), closes [#55](https://github.com/kaciakmaciak/react-query-subscription/issues/55) 48 | * **useSubscription:** pass `queryKey` to `subscriptionFn` ([aff5e3e](https://github.com/kaciakmaciak/react-query-subscription/commit/aff5e3eefb9865ea6c16bd879d41853bf3db8c7a)) 49 | 50 | ### [1.5.1](https://github.com/kaciakmaciak/react-query-subscription/compare/v1.5.0...v1.5.1) (2022-06-24) 51 | 52 | 53 | ### 🐛 Bug Fixes 54 | 55 | * **npm:** fix UMD build ([78d4fb8](https://github.com/kaciakmaciak/react-query-subscription/commit/78d4fb8cec48f9cc8b92925a8221e73d12de2909)), closes [#51](https://github.com/kaciakmaciak/react-query-subscription/issues/51) 56 | 57 | ## [1.5.0](https://github.com/kaciakmaciak/react-query-subscription/compare/v1.4.0...v1.5.0) (2022-05-30) 58 | 59 | 60 | ### ✨ Features 61 | 62 | * **useSubscription:** add `onData` option ([#34](https://github.com/kaciakmaciak/react-query-subscription/issues/34)) ([36a6859](https://github.com/kaciakmaciak/react-query-subscription/commit/36a68591a056be3afa2c396cc7aee9d9c2ac0ac3)) 63 | 64 | ## [1.4.0](https://github.com/kaciakmaciak/react-query-subscription/compare/v1.3.0...v1.4.0) (2021-11-07) 65 | 66 | ### ✨ Features 67 | 68 | - **useSubscription:** add `onError` option ([c102b7c](https://github.com/kaciakmaciak/react-query-subscription/commit/c102b7c771d3d2a894767fab28b531e9d3cf4ab5)), closes [#29](https://github.com/kaciakmaciak/react-query-subscription/issues/29) 69 | 70 | ## [1.3.0](https://github.com/kaciakmaciak/react-query-subscription/compare/v1.2.0...v1.3.0) (2021-11-02) 71 | 72 | ### ✨ Features 73 | 74 | - **useSubscription:** add `retryDelay` option ([2648116](https://github.com/kaciakmaciak/react-query-subscription/commit/26481160b41aebab807798663834df5b16596954)), closes [#24](https://github.com/kaciakmaciak/react-query-subscription/issues/24) 75 | 76 | ## [1.2.0](https://github.com/kaciakmaciak/react-query-subscription/compare/v1.1.0...v1.2.0) (2021-10-31) 77 | 78 | ### ✨ Features 79 | 80 | - **types:** export `UseSubscriptionOptions` and `EventSourceOptions` types ([3c497d8](https://github.com/kaciakmaciak/react-query-subscription/commit/3c497d8285784f0befa286b00edc1bbe46bc34b6)) 81 | 82 | ## [1.1.0](https://github.com/kaciakmaciak/react-query-subscription/compare/v1.0.0...v1.1.0) (2021-10-30) 83 | 84 | ### ✨ Features 85 | 86 | - **helpers:** add `fromEventSource` and `eventSource$` helpers ([38370dc](https://github.com/kaciakmaciak/react-query-subscription/commit/38370dc1b11435c86167db3ae2a1f4f0ea17d023)), closes [#13](https://github.com/kaciakmaciak/react-query-subscription/issues/13) 87 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | kaciak.maciak@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Katarina Anton 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Query `useSubscription` hook 2 | 3 | 4 | 5 | [all-contributors-badge]: https://img.shields.io/badge/all_contributors-2-orange.svg?style=round-square 'Number of contributors on All-Contributors' 6 | 7 | 8 | 9 | [![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/kaciakmaciak/react-query-subscription?display_name=tag&sort=semver)](https://github.com/kaciakmaciak/react-query-subscription/releases) 10 | [![All Contributors][all-contributors-badge]](#contributors-) 11 | [![Gitpod Ready-to-Code](https://img.shields.io/badge/Gitpod-ready--to--code-908a85?logo=gitpod)](https://gitpod.io/#https://github.com/kaciakmaciak/react-query-subscription) 12 | [![codecov](https://codecov.io/gh/kaciakmaciak/react-query-subscription/branch/master/graph/badge.svg)](https://codecov.io/gh/kaciakmaciak/react-query-subscription) 13 | ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/kaciakmaciak/react-query-subscription/release.yaml?branch=master) 14 | ![Snyk Vulnerabilities for GitHub Repo](https://img.shields.io/snyk/vulnerabilities/github/kaciakmaciak/react-query-subscription) 15 | ![Semantic release](https://img.shields.io/badge/%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079) 16 | [![npm bundle size](https://img.shields.io/bundlephobia/min/react-query-subscription)](https://www.npmjs.com/package/react-query-subscription) 17 | [![GitHub](https://img.shields.io/github/license/kaciakmaciak/react-query-subscription)](LICENSE) 18 | 19 | [API Reference](https://kaciakmaciak.github.io/react-query-subscription/) 20 | 21 | [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/kaciakmaciak/react-query-subscription) 22 | 23 | ## Background 24 | 25 | While React Query is very feature rich, it misses one thing - support for streams, event emitters, WebSockets etc. This library leverages React Query's `useQuery` to provide `useSubscription` hook for subscribing to real-time data. 26 | 27 | ## General enough solution 28 | 29 | React Query `useQuery`'s query function is _any_ function which returns a Promise. Similarly, `useSubscription`'s subscription function is _any_ function which returns an _Observable_. 30 | 31 | ## Installation 32 | 33 | ### NPM 34 | 35 | ```sh 36 | npm install react-query-subscription react react-query@3 rxjs@7 37 | ``` 38 | 39 | or 40 | 41 | ```sh 42 | yarn add react-query-subscription react react-query@3 rxjs@7 43 | ``` 44 | 45 | ## Use cases 46 | 47 | Please see [examples](./examples/README.md). 48 | 49 | ### Subscribe to WebSocket 50 | 51 | TODO 52 | 53 | ### Subscribe to Event source 54 | 55 | ```TypeScript 56 | import { QueryClientProvider, QueryClient } from 'react-query'; 57 | import { ReactQueryDevtools } from 'react-query/devtools'; 58 | import { useSubscription } from 'react-query-subscription'; 59 | import { eventSource$ } from 'rx-event-source'; 60 | 61 | const queryClient = new QueryClient(); 62 | 63 | function App() { 64 | return ( 65 | 66 | 67 | 68 | 69 | ); 70 | } 71 | 72 | function SseExample() { 73 | const { data, isLoading, isError, error } = useSubscription( 74 | 'some-key', 75 | // @see https://kaciakmaciak.github.io/rx-event-source/modules.html#eventSource_ 76 | () => eventSource$('/api/v1/sse'), 77 | { 78 | // options 79 | } 80 | ); 81 | 82 | if (isLoading) { 83 | return
Loading...
; 84 | } 85 | if (isError) { 86 | return ( 87 |
88 | {error?.message || 'Unknown error'} 89 |
90 | ); 91 | } 92 | return
Data: {JSON.stringify(data)}
; 93 | } 94 | ``` 95 | 96 | See [`rx-event-source` docs](https://kaciakmaciak.github.io/rx-event-source). 97 | 98 | ### GraphQL subscription using [`graphql-ws`](https://github.com/enisdenjo/graphql-ws) 99 | 100 | ```TypeScript 101 | import { QueryClientProvider, QueryClient } from 'react-query'; 102 | import { ReactQueryDevtools } from 'react-query/devtools'; 103 | import { useSubscription } from 'react-query-subscription'; 104 | import { Observable } from 'rxjs'; 105 | import { createClient } from 'graphql-ws'; 106 | import type { Client, SubscribePayload } from 'graphql-ws'; 107 | 108 | const queryClient = new QueryClient(); 109 | 110 | function App() { 111 | return ( 112 | 113 | 114 | 115 | 116 | ); 117 | } 118 | 119 | const client = createClient({ url: 'wss://example.com/graphql' }); 120 | 121 | /** 122 | * @see https://github.com/enisdenjo/graphql-ws#observable 123 | */ 124 | export function fromWsClientSubscription>( 125 | client: Client, 126 | payload: SubscribePayload 127 | ) { 128 | return new Observable((observer) => 129 | client.subscribe(payload, { 130 | next: (data) => observer.next(data.data), 131 | error: (err) => observer.error(err), 132 | complete: () => observer.complete(), 133 | }) 134 | ); 135 | } 136 | 137 | interface Props { 138 | postId: string; 139 | } 140 | 141 | interface Comment { 142 | id: string; 143 | content: string; 144 | } 145 | 146 | function GraphQlWsExample({ postId }: Props) { 147 | const { data, isLoading, isError, error } = useSubscription( 148 | 'some-key', 149 | () => fromWsClientSubscription<{ comments: Array }>({ 150 | query: ` 151 | subscription Comments($postId: ID!) { 152 | comments(postId: $postId) { 153 | id 154 | content 155 | } 156 | } 157 | `, 158 | variables: { 159 | postId, 160 | }, 161 | }), 162 | { 163 | // options 164 | } 165 | ); 166 | 167 | if (isLoading) { 168 | return
Loading...
; 169 | } 170 | if (isError) { 171 | return ( 172 |
173 | {error?.message || 'Unknown error'} 174 |
175 | ); 176 | } 177 | return
Data: {JSON.stringify(data?.comments)}
; 178 | } 179 | ``` 180 | 181 | 184 | 185 | ## Contributors ✨ 186 | 187 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 |

Katarina Anton

💻 🤔 🚧 ⚠️ 🔧 🚇

Jacob Cable

💻 🤔
198 | 199 | 200 | 201 | 202 | 203 | 204 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 205 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | ## `useInfiniteSubscription` 4 | 5 | The following examples are using `useInfiniteSubscription` hook in combination 6 | with other hooks - `useSubscription`, `useQuery` and/or `useMutation`. 7 | 8 | | Example | App description | Libraries used | Source code | 9 | | -------- | ------------------ | -------------------- | -------------------------- | 10 | | Firebase | Basic chatting app | `firebase`, `rxfire` | [Source code](./firebase/) | 11 | -------------------------------------------------------------------------------- /examples/firebase/.env: -------------------------------------------------------------------------------- 1 | ## ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 2 | # Firebase Config. 3 | # 4 | # 1. Create firestore database. 5 | # 2. Set the `firebaseConfig` variable. 6 | # 7 | # @see https://firebase.google.com/docs/firestore/quickstart#create 8 | ## ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 9 | VITE_FIREBASE_API_KEY= 10 | VITE_FIREBASE_AUTH_DOMAIN= 11 | VITE_FIREBASE_PROJECT_ID= 12 | VITE_FIREBASE_STORAGE_BUCKET= 13 | VITE_FIREBASE_MESSAGING_SENDER_ID= 14 | VITE_FIREBASE_APP_ID= 15 | -------------------------------------------------------------------------------- /examples/firebase/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["plugin:react/jsx-runtime"] 3 | } 4 | -------------------------------------------------------------------------------- /examples/firebase/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /examples/firebase/README.md: -------------------------------------------------------------------------------- 1 | # Example 2 | 3 | 1. Create firestore database as per [docs](https://firebase.google.com/docs/firestore/quickstart#create). 4 | 1. Set environment variables: 5 | 6 | ```sh 7 | # Copy the template .env file to local .env file 8 | cp .env .env.local 9 | # Edit .env.local with firebase config. 10 | ``` 11 | 12 | 1. Install dependencies (ideally in project root): 13 | 14 | ```sh 15 | npm install 16 | ``` 17 | 18 | > If you are running the command directly in the `examples/firebase` directory, use ` --ignore-scripts`. 19 | 20 | 1. Run local dev server: 21 | 22 | ```sh 23 | npm run dev 24 | ``` 25 | -------------------------------------------------------------------------------- /examples/firebase/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | react-query-subscription Firebase example 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/firebase/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-query-subscription-firebase-example", 3 | "private": true, 4 | "engines": { 5 | "npm": ">=8" 6 | }, 7 | "version": "0.0.0", 8 | "scripts": { 9 | "dev": "vite", 10 | "build": "tsc && vite build", 11 | "preview": "vite preview" 12 | }, 13 | "dependencies": { 14 | "@faker-js/faker": "7.1.0", 15 | "@fingerprintjs/fingerprintjs": "3.3.3", 16 | "@heroicons/react": "1.0.6", 17 | "clsx": "1.2.1", 18 | "date-fns": "2.28.0", 19 | "firebase": "9.8.2", 20 | "react": "^18.0.0", 21 | "react-dom": "^18.0.0", 22 | "react-intersection-observer": "9.4.0", 23 | "react-query": "3.39.2", 24 | "react-query-subscription": "*", 25 | "react-router-dom": "6.3.0", 26 | "rxfire": "6.0.3", 27 | "rxjs": "7.1.0", 28 | "spin-delay": "1.2.0", 29 | "tiny-invariant": "1.3.1" 30 | }, 31 | "devDependencies": { 32 | "@types/react": "^18.0.0", 33 | "@types/react-dom": "^18.0.0", 34 | "@vitejs/plugin-react": "^1.3.0", 35 | "typescript": "^4.6.3", 36 | "vite": "3.1.6" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /examples/firebase/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Routes, Route } from 'react-router-dom'; 2 | 3 | import { NavBar } from './components/NavBar'; 4 | 5 | import Layout from './routes/layout'; 6 | import IndexRoute from './routes/index'; 7 | import ChatRoute from './routes/chat/$id'; 8 | import AboutRoute from './routes/about'; 9 | 10 | function App() { 11 | return ( 12 | <> 13 | 14 | 15 | }> 16 | } /> 17 | } /> 18 | } /> 19 | 20 | 21 | 22 | ); 23 | } 24 | 25 | export default App; 26 | -------------------------------------------------------------------------------- /examples/firebase/src/api/messages.ts: -------------------------------------------------------------------------------- 1 | import { 2 | collection, 3 | query, 4 | orderBy, 5 | doc, 6 | addDoc, 7 | limit, 8 | startAfter, 9 | endAt, 10 | } from 'firebase/firestore'; 11 | import { collectionData } from 'rxfire/firestore'; 12 | import { map } from 'rxjs/operators'; 13 | 14 | import type { Observable } from 'rxjs'; 15 | import type { 16 | DocumentData, 17 | QueryDocumentSnapshot, 18 | SnapshotOptions, 19 | QueryConstraint, 20 | } from 'firebase/firestore'; 21 | 22 | import { db } from '../firebase'; 23 | 24 | import type { Message } from '../types/message'; 25 | import type { InfiniteData } from '../types/infinite-data'; 26 | 27 | export interface MessageDocument { 28 | id: string; 29 | userId: string; 30 | message: string; 31 | sentAt: number; 32 | } 33 | 34 | const messageConverter = { 35 | toFirestore(message: Message): DocumentData { 36 | return message; 37 | }, 38 | fromFirestore( 39 | snapshot: QueryDocumentSnapshot, 40 | options: SnapshotOptions 41 | ): Message { 42 | const data = snapshot.data(options); 43 | return { 44 | id: snapshot.id, 45 | userId: data.userId, 46 | message: data.message, 47 | sentAt: data.sentAt, 48 | }; 49 | }, 50 | }; 51 | 52 | export async function addMessage( 53 | chatId: string, 54 | message: Omit 55 | ): Promise { 56 | const messagesRef = collection( 57 | doc(db, 'messages', chatId), 58 | 'messages' 59 | ).withConverter(messageConverter); 60 | const ref = await addDoc(messagesRef, message); 61 | return { 62 | ...message, 63 | id: ref.id, 64 | }; 65 | } 66 | 67 | export type LiveMessagesCursor = { after: number }; 68 | export type PreviousMessagesCursor = { before: number; limit: number }; 69 | export type MessagesCursor = LiveMessagesCursor | PreviousMessagesCursor; 70 | 71 | function isLiveMessagesCursor( 72 | cursor: MessagesCursor 73 | ): cursor is LiveMessagesCursor { 74 | return 'after' in cursor; 75 | } 76 | 77 | export function getMessages$( 78 | chatId: string, 79 | cursor: MessagesCursor 80 | ): Observable> { 81 | const constraints: QueryConstraint[] = isLiveMessagesCursor(cursor) 82 | ? [orderBy('sentAt', 'desc'), endAt(cursor.after)] 83 | : [orderBy('sentAt', 'desc'), startAfter(cursor.before), limit(5)]; 84 | 85 | const messagesRef = query( 86 | collection(doc(db, 'messages', chatId), 'messages').withConverter( 87 | messageConverter 88 | ), 89 | ...constraints 90 | ); 91 | return collectionData(messagesRef, { idField: 'id' }).pipe( 92 | map((data) => ({ 93 | data, 94 | nextCursor: isLiveMessagesCursor(cursor) 95 | ? cursor.after 96 | : data[data.length - 1]?.sentAt, 97 | })) 98 | ); 99 | } 100 | -------------------------------------------------------------------------------- /examples/firebase/src/api/users.ts: -------------------------------------------------------------------------------- 1 | import { collection, query, where, getDocs, addDoc } from 'firebase/firestore'; 2 | import { collectionData } from 'rxfire/firestore'; 3 | 4 | import type { Observable } from 'rxjs'; 5 | import type { 6 | DocumentData, 7 | QueryDocumentSnapshot, 8 | SnapshotOptions, 9 | } from 'firebase/firestore'; 10 | 11 | import { db } from '../firebase'; 12 | 13 | import type { User } from '../types/user'; 14 | 15 | export interface UserDocument { 16 | id: string; 17 | fingerprint: string; 18 | username?: string; 19 | isOnline?: boolean; 20 | } 21 | 22 | const userConverter = { 23 | toFirestore(user: User): DocumentData { 24 | return user; 25 | }, 26 | fromFirestore( 27 | snapshot: QueryDocumentSnapshot, 28 | options: SnapshotOptions 29 | ): User { 30 | const data = snapshot.data(options); 31 | return { 32 | id: snapshot.id, 33 | fingerprint: data.fingerprint, 34 | username: data.username, 35 | isRandomName: Boolean(data.isRandomName), 36 | }; 37 | }, 38 | }; 39 | 40 | export async function findUser(fingerprint: string): Promise { 41 | const usersRef = query( 42 | collection(db, 'users').withConverter(userConverter), 43 | where('fingerprint', '==', fingerprint) 44 | ); 45 | const usersDocs = await getDocs(usersRef); 46 | if (usersDocs.empty) { 47 | return undefined; 48 | } 49 | if (usersDocs.size > 1) { 50 | throw new Error('Multiple users with same fingerprint'); 51 | } 52 | return usersDocs.docs[0].data() ?? undefined; 53 | } 54 | 55 | export async function addUser(user: Omit): Promise { 56 | const ref = await addDoc(collection(db, 'users'), user); 57 | return { 58 | ...user, 59 | id: ref.id, 60 | }; 61 | } 62 | 63 | export function getUsers$(): Observable { 64 | const usersRef = collection(db, 'users').withConverter(userConverter); 65 | return collectionData(usersRef, { idField: 'id' }); 66 | } 67 | -------------------------------------------------------------------------------- /examples/firebase/src/components/About/About.module.css: -------------------------------------------------------------------------------- 1 | .About { 2 | max-width: 80%; 3 | } 4 | 5 | .About code { 6 | display: inline-block; 7 | background-color: #fff; 8 | border-radius: 2px; 9 | padding: 2px 0.25rem; 10 | } 11 | -------------------------------------------------------------------------------- /examples/firebase/src/components/About/About.tsx: -------------------------------------------------------------------------------- 1 | import classes from './About.module.css'; 2 | 3 | export function About() { 4 | return ( 5 |
6 |

Firebase Example

7 |

8 | This basic messaging app demonstrates how to use{' '} 9 | useInfiniteSubscription hook together with Firebase and{' '} 10 | 15 | rxfire library 16 | 17 | . 18 |

19 |

How to use the app

20 |

21 | The messaging app uses 22 | 27 | fingerprintjs library 28 | {' '} 29 | to create an unique user. A random alias name is assigned per 30 | fingerprint. 31 |

32 |

33 | Once your user has been initialized, you can add new message to the 34 | chat. 35 |

36 |

37 | Different fingerprints are generated for different browsers on the same 38 | device. This way, you can test the app with multiple users. 39 |

40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /examples/firebase/src/components/About/index.ts: -------------------------------------------------------------------------------- 1 | export { About } from './About'; 2 | -------------------------------------------------------------------------------- /examples/firebase/src/components/Avatar/Avatar.module.css: -------------------------------------------------------------------------------- 1 | .Picture { 2 | width: 4rem; 3 | height: 4rem; 4 | background-size: cover; 5 | background-position: center; 6 | border-radius: 50%; 7 | } 8 | -------------------------------------------------------------------------------- /examples/firebase/src/components/Avatar/Avatar.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | 3 | import classes from './Avatar.module.css'; 4 | 5 | export interface AvatarProps { 6 | fingerprint: string; 7 | className?: string; 8 | } 9 | 10 | export function Avatar(props: AvatarProps) { 11 | return ( 12 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /examples/firebase/src/components/Avatar/index.ts: -------------------------------------------------------------------------------- 1 | export { Avatar } from './Avatar'; 2 | export type { AvatarProps } from './Avatar'; 3 | -------------------------------------------------------------------------------- /examples/firebase/src/components/Box/Box.module.css: -------------------------------------------------------------------------------- 1 | .Box { 2 | position: relative; 3 | 4 | display: flex; 5 | flex-direction: column; 6 | justify-content: space-between; 7 | 8 | width: 24rem; 9 | height: 38rem; 10 | z-index: 2; 11 | box-sizing: border-box; 12 | border-radius: 1rem; 13 | 14 | background: white; 15 | box-shadow: 0 0 8rem 0 rgba(0, 0, 0, 0.1), 16 | 0rem 2rem 4rem -3rem rgba(0, 0, 0, 0.5); 17 | } 18 | -------------------------------------------------------------------------------- /examples/firebase/src/components/Box/Box.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | 3 | import classes from './Box.module.css'; 4 | 5 | export interface BoxProps { 6 | children: React.ReactNode; 7 | className?: string; 8 | } 9 | 10 | export function Box(props: BoxProps) { 11 | return ( 12 |
{props.children}
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /examples/firebase/src/components/Box/index.ts: -------------------------------------------------------------------------------- 1 | export { Box } from './Box'; 2 | export type { BoxProps } from './Box'; 3 | -------------------------------------------------------------------------------- /examples/firebase/src/components/Chat/Chat.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '../Box'; 2 | import { User } from '../User'; 3 | import { Messages } from '../Messages'; 4 | import { NewMessage } from '../NewMessage'; 5 | 6 | export interface ChatProps { 7 | chatId: string; 8 | } 9 | 10 | export function Chat(props: ChatProps) { 11 | const { chatId } = props; 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /examples/firebase/src/components/Chat/index.ts: -------------------------------------------------------------------------------- 1 | export { Chat } from './Chat'; 2 | -------------------------------------------------------------------------------- /examples/firebase/src/components/ChatList/ChatList.module.css: -------------------------------------------------------------------------------- 1 | .Wrapper { 2 | flex: 1 1 0; 3 | padding: 0 2rem; 4 | text-align: center; 5 | } 6 | 7 | .ChatList { 8 | list-style: none; 9 | margin: 2.5rem 0; 10 | padding: 0; 11 | display: flex; 12 | flex-direction: column; 13 | gap: 0.5rem; 14 | } 15 | 16 | .ChatListItem { 17 | display: flex; 18 | align-items: center; 19 | gap: 1rem; 20 | padding: 1rem; 21 | background-color: #efefef; 22 | border-radius: 0.5rem; 23 | text-decoration: none; 24 | color: #000; 25 | box-shadow: inset 0 2rem 2rem -2rem rgba(0, 0, 0, 0.05), 26 | inset 0 -2rem 2rem -2rem rgba(0, 0, 0, 0.05); 27 | } 28 | 29 | .ChatListItem .ChatName { 30 | flex: 1 1 auto; 31 | text-align: left; 32 | } 33 | -------------------------------------------------------------------------------- /examples/firebase/src/components/ChatList/ChatList.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom'; 2 | 3 | import { Box } from '../Box'; 4 | import { User } from '../User'; 5 | 6 | import classes from './ChatList.module.css'; 7 | 8 | const chats = ['intro', 'chitchat', 'secret']; 9 | 10 | export function ChatList() { 11 | return ( 12 | 13 | 14 |
15 |

Welcome to Chit-Chat

16 |
    17 | {chats?.map((chatId) => ( 18 |
  • 19 | 20 | {chatId} 21 | 22 |
  • 23 | ))} 24 |
25 |
26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /examples/firebase/src/components/ChatList/index.ts: -------------------------------------------------------------------------------- 1 | export { ChatList } from './ChatList'; 2 | -------------------------------------------------------------------------------- /examples/firebase/src/components/Messages/Messages.module.css: -------------------------------------------------------------------------------- 1 | .Messages { 2 | display: flex; 3 | flex-direction: column-reverse; 4 | padding: 1rem; 5 | background: var(--background); 6 | flex-shrink: 2; 7 | overflow-y: auto; 8 | height: 100%; 9 | 10 | box-shadow: inset 0 2rem 2rem -2rem rgba(0, 0, 0, 0.05), 11 | inset 0 -2rem 2rem -2rem rgba(0, 0, 0, 0.05); 12 | } 13 | 14 | .Status { 15 | margin: 1rem auto; 16 | } 17 | 18 | .Message { 19 | display: grid; 20 | grid-template-columns: min-content auto; 21 | grid-template-rows: auto auto; 22 | grid-template-areas: 23 | 'avatar title' 24 | 'message message'; 25 | margin: 1rem 0; 26 | } 27 | .Message.Us { 28 | grid-template-columns: auto min-content; 29 | grid-template-areas: 30 | 'title avatar' 31 | 'message message'; 32 | } 33 | 34 | .Message .Picture { 35 | grid-area: avatar; 36 | background-color: #fff; 37 | width: 2rem; 38 | height: 2rem; 39 | box-shadow: 0 0 2rem rgba(0, 0, 0, 0.075), 40 | 0rem 1rem 1rem -1rem rgba(0, 0, 0, 0.1); 41 | } 42 | 43 | .MessageTitle { 44 | grid-area: title; 45 | align-self: center; 46 | padding: 0 0.5rem; 47 | font-weight: 500; 48 | } 49 | .Us .MessageTitle { 50 | justify-self: end; 51 | } 52 | 53 | .MessageText { 54 | grid-area: message; 55 | } 56 | .Us .MessageText { 57 | justify-self: end; 58 | } 59 | 60 | .MessageTime { 61 | font-size: 0.8rem; 62 | background: var(--time-bg); 63 | padding: 0.25rem 1rem; 64 | border-radius: 2rem; 65 | color: var(--text-3); 66 | width: fit-content; 67 | margin: 0 auto; 68 | } 69 | 70 | .MessageText { 71 | box-sizing: border-box; 72 | padding: 0.5rem 1rem; 73 | margin: 1rem; 74 | background: #fff; 75 | border-radius: 0 1.125rem 1.125rem 1.125rem; 76 | min-height: 2.25rem; 77 | width: fit-content; 78 | max-width: 66%; 79 | 80 | box-shadow: 0 0 2rem rgba(0, 0, 0, 0.075), 81 | 0rem 1rem 1rem -1rem rgba(0, 0, 0, 0.1); 82 | } 83 | 84 | .Us .MessageText { 85 | border-radius: 1.125rem 0 1.125rem 1.125rem; 86 | background: var(--text-1); 87 | color: white; 88 | } 89 | -------------------------------------------------------------------------------- /examples/firebase/src/components/Messages/Messages.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, useEffect, useRef } from 'react'; 2 | import { format } from 'date-fns'; 3 | import { useInView } from 'react-intersection-observer'; 4 | import { useSpinDelay } from 'spin-delay'; 5 | import clsx from 'clsx'; 6 | 7 | import { 8 | useMessagesSubscription, 9 | useUsersSubscription, 10 | useUserQuery, 11 | } from '../../hooks/api'; 12 | 13 | import { Avatar } from '../Avatar'; 14 | 15 | import classes from './Messages.module.css'; 16 | 17 | export interface MessagesProps { 18 | chatId: string; 19 | } 20 | 21 | export function Messages(props: MessagesProps) { 22 | const { chatId } = props; 23 | const { 24 | data: user, 25 | isLoading: isLoadingUser, 26 | isError: isUserError, 27 | } = useUserQuery(); 28 | const { 29 | data: messages, 30 | isLoading: isLoadingMessages, 31 | isError: isMessagesError, 32 | isFetchingNextPage, 33 | hasNextPage, 34 | fetchNextPage, 35 | } = useMessagesSubscription(chatId); 36 | const { 37 | data: users, 38 | isLoading: isLoadingUsers, 39 | isError: isUsersError, 40 | } = useUsersSubscription(); 41 | 42 | const scrollRef = useRef(null); 43 | 44 | const veryLastMessage = messages?.pages[0]?.data[0]; 45 | useEffect(() => { 46 | if (veryLastMessage?.userId === user?.id) { 47 | scrollRef.current?.scrollIntoView({ behavior: 'smooth' }); 48 | } 49 | }, [veryLastMessage?.id, veryLastMessage?.userId, user]); 50 | 51 | return ( 52 |
53 |
103 | ); 104 | } 105 | 106 | interface LoadMoreProps { 107 | isLoadingMore: boolean; 108 | onLoadMore: () => void; 109 | } 110 | 111 | function LoadMore(props: LoadMoreProps) { 112 | const { isLoadingMore, onLoadMore } = props; 113 | const showLoadingIndicator = useSpinDelay(isLoadingMore, { 114 | delay: 500, 115 | minDuration: 500, 116 | }); 117 | 118 | const { ref, inView } = useInView({}); 119 | 120 | useEffect(() => { 121 | if (!inView && !isLoadingMore) return; 122 | onLoadMore(); 123 | }, [inView, isLoadingMore, onLoadMore]); 124 | 125 | return ( 126 |
127 | {showLoadingIndicator && 'Loading...'} 128 |
129 | ); 130 | } 131 | -------------------------------------------------------------------------------- /examples/firebase/src/components/Messages/index.ts: -------------------------------------------------------------------------------- 1 | export { Messages } from './Messages'; 2 | -------------------------------------------------------------------------------- /examples/firebase/src/components/NavBar/NavBar.module.css: -------------------------------------------------------------------------------- 1 | .NavBar { 2 | position: fixed; 3 | right: 0; 4 | top: 0; 5 | padding: 1rem; 6 | display: flex; 7 | flex-direction: column; 8 | gap: 0.5rem; 9 | } 10 | 11 | .NavBarItem { 12 | position: relative; 13 | display: block; 14 | box-sizing: border-box; 15 | width: 3rem; 16 | height: 3rem; 17 | background-color: #333; 18 | color: #fff; 19 | border-radius: 9999px; 20 | padding: 0.75rem; 21 | } 22 | -------------------------------------------------------------------------------- /examples/firebase/src/components/NavBar/NavBar.tsx: -------------------------------------------------------------------------------- 1 | import { Link, useMatch } from 'react-router-dom'; 2 | import { 3 | ViewListIcon, 4 | QuestionMarkCircleIcon, 5 | ArrowNarrowLeftIcon, 6 | } from '@heroicons/react/outline'; 7 | 8 | import classes from './NavBar.module.css'; 9 | 10 | function BackToChatLink() { 11 | return ( 12 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 13 | 14 | 15 | 16 | ); 17 | } 18 | 19 | export function NavBar() { 20 | const chatMatch = useMatch('chat/:chatId'); 21 | const aboutMatch = useMatch('about'); 22 | return ( 23 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /examples/firebase/src/components/NavBar/index.ts: -------------------------------------------------------------------------------- 1 | export { NavBar } from './NavBar'; 2 | -------------------------------------------------------------------------------- /examples/firebase/src/components/NewMessage/NewMessage.module.css: -------------------------------------------------------------------------------- 1 | .NewMessage { 2 | box-sizing: border-box; 3 | flex-basis: 4rem; 4 | flex-shrink: 0; 5 | display: flex; 6 | align-items: center; 7 | } 8 | 9 | .NewMessage i { 10 | font-size: 1.5rem; 11 | margin-right: 1rem; 12 | color: var(--text-2); 13 | cursor: pointer; 14 | transition: color 200ms; 15 | } 16 | .NewMessage i:hover { 17 | color: var(--text-1); 18 | } 19 | 20 | .NewMessage input { 21 | border: none; 22 | background-image: none; 23 | background-color: white; 24 | padding: 0.5rem 1rem; 25 | margin-right: 1rem; 26 | border-radius: 1.125rem; 27 | flex-grow: 2; 28 | box-shadow: 0 0 1rem rgba(0, 0, 0, 0.1), 29 | 0rem 1rem 1rem -1rem rgba(0, 0, 0, 0.2); 30 | 31 | font-family: Red hat Display, sans-serif; 32 | font-weight: 400; 33 | letter-spacing: 0.025em; 34 | } 35 | 36 | .NewMessage input:placeholder { 37 | color: var(--text-3); 38 | } 39 | -------------------------------------------------------------------------------- /examples/firebase/src/components/NewMessage/NewMessage.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | 3 | import { useNewMessageMutation, useUserQuery } from '../../hooks/api'; 4 | 5 | import classes from './NewMessage.module.css'; 6 | 7 | export interface NewMessageProps { 8 | chatId: string; 9 | } 10 | 11 | export function NewMessage(props: NewMessageProps) { 12 | const { chatId } = props; 13 | 14 | const { data: user } = useUserQuery(); 15 | const { mutate: addMessage } = useNewMessageMutation(chatId); 16 | 17 | const inputRef = useRef(null); 18 | 19 | const handleSubmit = (event: React.FormEvent) => { 20 | event.preventDefault(); 21 | 22 | const inputValue = inputRef.current?.value; 23 | if (!user?.id || !inputValue) return; 24 | 25 | addMessage({ 26 | userId: user.id, 27 | message: inputValue, 28 | sentAt: Date.now(), 29 | }); 30 | 31 | inputRef.current.value = ''; 32 | }; 33 | return ( 34 |
35 | 36 | 37 | 45 | 46 |
47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /examples/firebase/src/components/NewMessage/index.ts: -------------------------------------------------------------------------------- 1 | export { NewMessage } from './NewMessage'; 2 | -------------------------------------------------------------------------------- /examples/firebase/src/components/User/User.module.css: -------------------------------------------------------------------------------- 1 | .User { 2 | position: relative; 3 | margin-bottom: 1rem; 4 | padding-left: 5rem; 5 | height: 4.5rem; 6 | 7 | display: flex; 8 | flex-direction: column; 9 | justify-content: center; 10 | } 11 | 12 | .Bar { 13 | flex-basis: 3.5rem; 14 | flex-shrink: 0; 15 | margin: 1rem; 16 | box-sizing: border-box; 17 | } 18 | 19 | .User .Picture { 20 | position: absolute; 21 | left: 0; 22 | box-shadow: 0 0 2rem rgba(0, 0, 0, 0.075), 23 | 0rem 1rem 1rem -1rem rgba(0, 0, 0, 0.1); 24 | border: 1px solid rgba(0, 0, 0, 0.15); 25 | } 26 | 27 | .User .Name { 28 | font-weight: 500; 29 | margin-bottom: 0.125rem; 30 | } 31 | 32 | .User .NameDetail { 33 | font-size: 0.9rem; 34 | color: var(--text-3); 35 | } 36 | -------------------------------------------------------------------------------- /examples/firebase/src/components/User/User.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | 3 | import { useUserQuery } from '../../hooks/api'; 4 | 5 | import { Avatar } from '../Avatar'; 6 | 7 | import classes from './User.module.css'; 8 | 9 | export function User() { 10 | const { data: user } = useUserQuery(); 11 | if (!user) return null; 12 | return ( 13 |
14 | 15 |
{user.username}
16 |
17 | {user.isRandomName && 'Random Alias'} 18 |
19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /examples/firebase/src/components/User/index.ts: -------------------------------------------------------------------------------- 1 | export { User } from './User'; 2 | -------------------------------------------------------------------------------- /examples/firebase/src/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /examples/firebase/src/fingerprint.ts: -------------------------------------------------------------------------------- 1 | import FingerprintJS from '@fingerprintjs/fingerprintjs'; 2 | 3 | const fpPromise = FingerprintJS.load({ 4 | monitoring: false, 5 | }); 6 | 7 | export async function fingerprint(): Promise { 8 | const fp = await fpPromise; 9 | return (await fp.get()).visitorId; 10 | } 11 | -------------------------------------------------------------------------------- /examples/firebase/src/firebase.ts: -------------------------------------------------------------------------------- 1 | import { initializeApp } from 'firebase/app'; 2 | import { getAuth } from 'firebase/auth'; 3 | import { getFirestore } from 'firebase/firestore'; 4 | 5 | /** 6 | * Firebase Config. 7 | * 8 | * 1. Create firestore database. 9 | * 2. Set the `firebaseConfig` variable. 10 | * 11 | * @see https://firebase.google.com/docs/firestore/quickstart#create 12 | */ 13 | const firebaseConfig = { 14 | apiKey: import.meta.env.VITE_FIREBASE_API_KEY, 15 | authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN, 16 | projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID, 17 | storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET, 18 | messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID, 19 | appId: import.meta.env.VITE_FIREBASE_APP_ID, 20 | }; 21 | if (Object.values(firebaseConfig).filter(Boolean).length === 0) { 22 | throw new Error('Configure env variables in .env.local`'); 23 | } 24 | 25 | export const firebaseApp = initializeApp(firebaseConfig); 26 | 27 | export const db = getFirestore(firebaseApp); 28 | export const auth = getAuth(firebaseApp); 29 | -------------------------------------------------------------------------------- /examples/firebase/src/hooks/api.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQuery } from 'react-query'; 2 | import { 3 | useSubscription, 4 | useInfiniteSubscription, 5 | } from 'react-query-subscription'; 6 | import { faker } from '@faker-js/faker'; 7 | 8 | import type { UseMutationOptions } from 'react-query'; 9 | import type { 10 | UseSubscriptionOptions, 11 | UseInfiniteSubscriptionOptions, 12 | } from 'react-query-subscription'; 13 | 14 | import { getUsers$, findUser, addUser } from '../api/users'; 15 | import { addMessage, getMessages$ } from '../api/messages'; 16 | 17 | import { useFingerprint } from './fingerprint'; 18 | 19 | import type { User } from '../types/user'; 20 | import type { Message } from '../types/message'; 21 | import type { InfiniteData } from '../types/infinite-data'; 22 | 23 | export function useUserQuery() { 24 | const { data: fingerprint } = useFingerprint(); 25 | return useQuery( 26 | ['user', fingerprint] as const, 27 | async ({ queryKey }) => { 28 | const [, fingerprint] = queryKey; 29 | if (!fingerprint) return undefined; 30 | 31 | const user = await findUser(fingerprint); 32 | if (!user) { 33 | return addUser({ 34 | fingerprint, 35 | username: `${faker.name.firstName()} ${faker.name.lastName()}`, 36 | isRandomName: true, 37 | }); 38 | } 39 | return user; 40 | }, 41 | { enabled: Boolean(fingerprint) } 42 | ); 43 | } 44 | 45 | export function useUsersSubscription( 46 | options: UseSubscriptionOptions = {} 47 | ) { 48 | return useSubscription('users', () => getUsers$(), options); 49 | } 50 | 51 | export function useMessagesSubscription>( 52 | chatId: string, 53 | options: UseInfiniteSubscriptionOptions< 54 | InfiniteData, 55 | Error, 56 | Data 57 | > = {} 58 | ) { 59 | return useInfiniteSubscription( 60 | ['messages', chatId], 61 | ({ pageParam }) => 62 | getMessages$( 63 | chatId, 64 | pageParam ? { before: pageParam, limit: 5 } : { after: Date.now() } 65 | ), 66 | { 67 | ...options, 68 | getNextPageParam: (lastPage) => lastPage.nextCursor, 69 | } 70 | ); 71 | } 72 | 73 | export function useNewMessageMutation( 74 | chatId: string, 75 | options: UseMutationOptions> = {} 76 | ) { 77 | return useMutation((message) => addMessage(chatId, message), options); 78 | } 79 | -------------------------------------------------------------------------------- /examples/firebase/src/hooks/fingerprint.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from 'react-query'; 2 | 3 | import { fingerprint } from '../fingerprint'; 4 | 5 | export function useFingerprint() { 6 | return useQuery('fingerprint', () => fingerprint(), { 7 | refetchOnMount: false, 8 | refetchOnReconnect: false, 9 | refetchOnWindowFocus: false, 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /examples/firebase/src/index.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css?family=Red+Hat+Display:400,500,900&display=swap'); 2 | 3 | :root { 4 | --text-1: #333; 5 | --text-2: #666; 6 | --text-3: #999; 7 | --line: #ccc; 8 | --time-bg: #eee; 9 | --background: #f7f7f7; 10 | } 11 | 12 | body, 13 | html { 14 | font-family: Red hat Display, sans-serif; 15 | -webkit-font-smoothing: antialiased; 16 | -moz-osx-font-smoothing: grayscale; 17 | font-weight: 400; 18 | line-height: 1.25em; 19 | letter-spacing: 0.025em; 20 | color: var(--text-1); 21 | background: var(--background); 22 | margin: 0; 23 | } 24 | -------------------------------------------------------------------------------- /examples/firebase/src/main.tsx: -------------------------------------------------------------------------------- 1 | import './index.css'; 2 | 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom/client'; 5 | import { BrowserRouter } from 'react-router-dom'; 6 | import { QueryClientProvider, QueryClient } from 'react-query'; 7 | import { ReactQueryDevtools } from 'react-query/devtools'; 8 | 9 | import App from './App'; 10 | 11 | const queryClient = new QueryClient(); 12 | 13 | ReactDOM.createRoot(document.getElementById('root')!).render( 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | -------------------------------------------------------------------------------- /examples/firebase/src/routes/about.tsx: -------------------------------------------------------------------------------- 1 | import { About } from '../components/About'; 2 | 3 | function AboutRoute() { 4 | return ; 5 | } 6 | 7 | export default AboutRoute; 8 | -------------------------------------------------------------------------------- /examples/firebase/src/routes/chat/$id.tsx: -------------------------------------------------------------------------------- 1 | import { useParams } from 'react-router-dom'; 2 | import invariant from 'tiny-invariant'; 3 | 4 | import { Chat } from '../../components/Chat'; 5 | 6 | function ChatRoute() { 7 | const { chatId } = useParams(); 8 | invariant(chatId, ':chatId param must be set.'); 9 | 10 | return ; 11 | } 12 | 13 | export default ChatRoute; 14 | -------------------------------------------------------------------------------- /examples/firebase/src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { ChatList } from '../components/ChatList'; 2 | 3 | function IndexRoute() { 4 | return ; 5 | } 6 | 7 | export default IndexRoute; 8 | -------------------------------------------------------------------------------- /examples/firebase/src/routes/layout.module.css: -------------------------------------------------------------------------------- 1 | .Layout { 2 | margin: 2rem; 3 | display: flex; 4 | flex-direction: column; 5 | align-items: center; 6 | } 7 | -------------------------------------------------------------------------------- /examples/firebase/src/routes/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from 'react-router-dom'; 2 | 3 | import classes from './layout.module.css'; 4 | 5 | function Layout() { 6 | return ( 7 |
8 | 9 |
10 | ); 11 | } 12 | 13 | export default Layout; 14 | -------------------------------------------------------------------------------- /examples/firebase/src/types/infinite-data.ts: -------------------------------------------------------------------------------- 1 | export interface InfiniteData { 2 | data: T; 3 | nextCursor?: P; 4 | previousCursor?: P; 5 | } 6 | -------------------------------------------------------------------------------- /examples/firebase/src/types/message.ts: -------------------------------------------------------------------------------- 1 | export interface Message { 2 | id: string; 3 | userId: string; 4 | message: string; 5 | sentAt: number; 6 | } 7 | -------------------------------------------------------------------------------- /examples/firebase/src/types/user.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | id: string; 3 | fingerprint: string; 4 | username: string; 5 | isRandomName?: boolean; 6 | } 7 | -------------------------------------------------------------------------------- /examples/firebase/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /examples/firebase/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"], 20 | "references": [{ "path": "./tsconfig.node.json" }] 21 | } 22 | -------------------------------------------------------------------------------- /examples/firebase/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "esnext", 5 | "moduleResolution": "node" 6 | }, 7 | "include": ["vite.config.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /examples/firebase/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }); 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-query-subscription", 3 | "version": "1.8.1", 4 | "engines": { 5 | "node": ">=10.13.0" 6 | }, 7 | "description": "Hook for managing, caching and syncing observables in React", 8 | "author": { 9 | "name": "Katarina Anton" 10 | }, 11 | "keywords": [ 12 | "react", 13 | "react-query", 14 | "real-time", 15 | "subscription" 16 | ], 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/kaciakmaciak/react-query-subscription" 20 | }, 21 | "license": "MIT", 22 | "publishConfig": { 23 | "access": "public" 24 | }, 25 | "main": "lib/index.js", 26 | "module": "es/index.js", 27 | "unpkg": "dist/react-query-subscription.umd.js", 28 | "types": "types/index.d.ts", 29 | "sideEffects": [], 30 | "files": [ 31 | "dist", 32 | "lib", 33 | "es", 34 | "types" 35 | ], 36 | "workspaces": [ 37 | "examples/*" 38 | ], 39 | "scripts": { 40 | "prepare": "husky install", 41 | "prerelease": "npm run build", 42 | "release": "semantic-release", 43 | "build": "npm run build:cjs && npm run build:esm && npm run build:umd && npm run build:types", 44 | "build:cjs": "rimraf ./lib && cross-env BABEL_ENV=commonjs babel --extensions .ts,.tsx --ignore ./src/**/tests/**/* ./src --out-dir lib", 45 | "build:esm": "rimraf ./es && babel --extensions .ts,.tsx --ignore ./src/**/tests/**/* ./src --out-dir es", 46 | "build:umd": "rimraf ./dist && cross-env NODE_ENV=production rollup -c && rollup-plugin-visualizer stats.json", 47 | "build:types": "rimraf ./types && tsc --project ./tsconfig.types.json", 48 | "docs:pages": "typedoc --readme ./README.md", 49 | "stats": "open ./stats.html", 50 | "test": "vitest", 51 | "test:watch": "vitest --watch", 52 | "lint": "eslint src --ext js,jsx,ts,tsx", 53 | "format": "prettier --write .", 54 | "format:check": "prettier --check .", 55 | "typecheck": "tsc --noEmit true" 56 | }, 57 | "lint-staged": { 58 | "src/**/!(__tests__)/*.{js,ts,jsx,tsx}": [ 59 | "pretty-quick --staged", 60 | "npm run lint -- --fix" 61 | ], 62 | "*.(md|html|json)": [ 63 | "pretty-quick --staged" 64 | ] 65 | }, 66 | "commitlint": { 67 | "extends": [ 68 | "@commitlint/config-conventional" 69 | ] 70 | }, 71 | "config": { 72 | "commitizen": { 73 | "path": "./node_modules/cz-conventional-changelog" 74 | } 75 | }, 76 | "peerDependencies": { 77 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0", 78 | "react-query": "^3.0.0", 79 | "rxjs": "^7.1.0" 80 | }, 81 | "dependencies": { 82 | "@babel/runtime": "7.14.6" 83 | }, 84 | "devDependencies": { 85 | "@babel/cli": "7.15.7", 86 | "@babel/core": "7.14.6", 87 | "@babel/plugin-transform-runtime": "7.14.5", 88 | "@babel/preset-env": "7.14.7", 89 | "@babel/preset-react": "7.14.5", 90 | "@babel/preset-typescript": "^7.18.6", 91 | "@commitlint/cli": "12.1.4", 92 | "@commitlint/config-conventional": "12.1.4", 93 | "@rollup/plugin-babel": "5.3.0", 94 | "@rollup/plugin-commonjs": "19.0.0", 95 | "@rollup/plugin-node-resolve": "13.0.0", 96 | "@rollup/plugin-replace": "2.4.2", 97 | "@semantic-release/changelog": "6.0.0", 98 | "@semantic-release/git": "10.0.0", 99 | "@testing-library/jest-dom": "5.14.1", 100 | "@testing-library/react": "12.0.0", 101 | "@testing-library/react-hooks": "7.0.1", 102 | "@types/eventsource": "^1.1.10", 103 | "@types/jest": "26.0.23", 104 | "@typescript-eslint/eslint-plugin": "^5.47.0", 105 | "@typescript-eslint/parser": "^5.47.0", 106 | "@vitest/coverage-c8": "^0.26.0", 107 | "babel-jest": "27.0.6", 108 | "conventional-changelog-conventionalcommits": "4.6.1", 109 | "cross-env": "7.0.3", 110 | "cz-conventional-changelog": "3.0.1", 111 | "eslint": "7.29.0", 112 | "eslint-config-prettier": "8.3.0", 113 | "eslint-config-standard": "16.0.3", 114 | "eslint-plugin-import": "2.23.4", 115 | "eslint-plugin-jest-dom": "3.9.0", 116 | "eslint-plugin-jsx-a11y": "6.4.1", 117 | "eslint-plugin-node": "11.1.0", 118 | "eslint-plugin-prettier": "3.4.0", 119 | "eslint-plugin-promise": "5.1.0", 120 | "eslint-plugin-react": "7.24.0", 121 | "eslint-plugin-react-hooks": "4.2.0", 122 | "eslint-plugin-testing-library": "4.6.0", 123 | "eventsource": "2.0.2", 124 | "husky": "7.0.0", 125 | "jsdom": "^20.0.3", 126 | "lint-staged": "11.0.0", 127 | "msw": "0.35.0", 128 | "prettier": "2.3.2", 129 | "pretty-quick": "3.1.1", 130 | "react": "^17.0.2", 131 | "react-dom": "17.0.2", 132 | "react-error-boundary": "3.1.3", 133 | "react-query": "3.39.2", 134 | "rimraf": "3.0.2", 135 | "rollup": "2.52.6", 136 | "rollup-plugin-peer-deps-external": "2.2.4", 137 | "rollup-plugin-sizes": "1.0.4", 138 | "rollup-plugin-terser": "7.0.2", 139 | "rollup-plugin-visualizer": "5.5.2", 140 | "rxjs": "7.1.0", 141 | "semantic-release": "19.0.3", 142 | "typedoc": "^0.23.23", 143 | "typescript": "^4.9.4", 144 | "vitest": "^0.26.0" 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | import peerDepsExternal from 'rollup-plugin-peer-deps-external'; 4 | import resolve from '@rollup/plugin-node-resolve'; 5 | import commonjs from '@rollup/plugin-commonjs'; 6 | import replace from '@rollup/plugin-replace'; 7 | import babel from '@rollup/plugin-babel'; 8 | import { terser } from 'rollup-plugin-terser'; 9 | import sizes from 'rollup-plugin-sizes'; 10 | import visualizer from 'rollup-plugin-visualizer'; 11 | 12 | // eslint-disable-next-line @typescript-eslint/no-var-requires,no-undef 13 | const packageJson = require('./package.json'); 14 | const license = fs.readFileSync(path.resolve(__dirname, './LICENSE'), 'utf-8'); 15 | 16 | const external = ['react', 'react-dom', 'react-query', 'rxjs']; 17 | const globals = { 18 | react: 'React', 19 | 'react-dom': 'ReactDOM', 20 | 'react-query': 'ReactQuery', 21 | rxjs: 'rxjs', 22 | 'rxjs/operators': 'rxjs.operators', 23 | }; 24 | 25 | const inputSources = [ 26 | ['src/index.ts', 'ReactQuerySubscription', 'react-query-subscription'], 27 | ]; 28 | const extensions = ['.js', '.jsx', '.es6', '.es', '.mjs', '.ts', '.tsx']; 29 | 30 | const babelConfig = { 31 | extensions, 32 | babelHelpers: 'runtime', 33 | babelrc: true, 34 | exclude: '**/node_modules/**', 35 | }; 36 | const resolveConfig = { browser: true, extensions }; 37 | 38 | // Throw on warning 39 | function onwarn(warning) { 40 | throw Error(warning.message); 41 | } 42 | 43 | const createBanner = (name, file) => `/** 44 | * @license ${name} v${packageJson.version} 45 | * ${file} 46 | * 47 | * ${license.replace(/\n$/, '').replace(/\n/g, '\n * ')} 48 | */ 49 | `; 50 | 51 | export default inputSources 52 | .map(([input, name, file]) => { 53 | return [ 54 | { 55 | input, 56 | onwarn, 57 | output: { 58 | file: `dist/${file}.development.js`, 59 | format: 'umd', 60 | name, 61 | sourcemap: true, 62 | banner: createBanner(name, `${file}.development.js`), 63 | globals, 64 | }, 65 | external, 66 | plugins: [ 67 | resolve(resolveConfig), 68 | babel(babelConfig), 69 | commonjs(), 70 | peerDepsExternal(), 71 | ], 72 | }, 73 | { 74 | input: input, 75 | output: { 76 | file: `dist/${file}.production.min.js`, 77 | format: 'umd', 78 | name, 79 | sourcemap: true, 80 | banner: createBanner(name, `${file}.production.min.js`), 81 | globals, 82 | }, 83 | external, 84 | plugins: [ 85 | replace({ 86 | 'process.env.NODE_ENV': JSON.stringify('production'), 87 | delimiters: ['', ''], 88 | preventAssignment: true, 89 | }), 90 | resolve(resolveConfig), 91 | babel(babelConfig), 92 | commonjs(), 93 | peerDepsExternal(), 94 | terser(), 95 | sizes(), 96 | visualizer({ 97 | filename: 'stats.json', 98 | json: true, 99 | }), 100 | ], 101 | }, 102 | ]; 103 | }) 104 | .flat(); 105 | -------------------------------------------------------------------------------- /src/__api-mocks__/handlers/index.ts: -------------------------------------------------------------------------------- 1 | import { handlers as sseHandlers } from './sse'; 2 | 3 | export const handlers = [...sseHandlers]; 4 | -------------------------------------------------------------------------------- /src/__api-mocks__/handlers/sse.ts: -------------------------------------------------------------------------------- 1 | import { rest } from 'msw'; 2 | 3 | import { createSseResponse } from './utils'; 4 | 5 | export const handlers = [ 6 | rest.get(`/sse`, (_, res, ctx) => { 7 | return createSseResponse(res, ctx, [ 8 | 'hello', 9 | 'world', 10 | 123, 11 | 'green', 12 | { test: 'red' }, 13 | 'complete', 14 | ]); 15 | }), 16 | rest.get(`/sse/error`, (_, res, ctx) => { 17 | return createSseResponse(res, ctx, [ 18 | 'hello', 19 | 'world', 20 | 123, 21 | new Error('Test Error'), 22 | ]); 23 | }), 24 | rest.get(`/sse/network-error`, (_, res) => { 25 | return res.networkError('Failed to connect'); 26 | }), 27 | ]; 28 | -------------------------------------------------------------------------------- /src/__api-mocks__/handlers/utils.ts: -------------------------------------------------------------------------------- 1 | import type { ResponseComposition, RestContext } from 'msw'; 2 | 3 | /** 4 | * Returns SSE message. 5 | */ 6 | function createSseMessage(eventType: string, data: unknown) { 7 | return `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`; 8 | } 9 | 10 | /** 11 | * Returns SSE error message. 12 | */ 13 | function createSseErrorMessage(errorMessage: string) { 14 | return createSseMessage('error', { 15 | errorMessage, 16 | }); 17 | } 18 | 19 | /** 20 | * Returns SSE complete message. 21 | */ 22 | function createSseCompleteMessage() { 23 | return createSseMessage('complete', null); 24 | } 25 | 26 | /** 27 | * Returns SSE response with specified values. 28 | * If any of the values is instance of error, emit error message. 29 | * Stop on "complete" message. 30 | */ 31 | export function createSseResponse( 32 | res: ResponseComposition, 33 | ctx: RestContext, 34 | values: Array 35 | ) { 36 | return res( 37 | ctx.status(200), 38 | ctx.set({ 39 | 'Cache-Control': 'no-cache', 40 | 'Content-Type': 'text/event-stream', 41 | Connection: 'keep-alive', 42 | }), 43 | ctx.delay(10), 44 | // We cannot stream body, so sending all messages at once. 45 | ctx.body( 46 | values 47 | .map((value) => { 48 | if (value instanceof Error) { 49 | return createSseErrorMessage(value.message); 50 | } 51 | if (value === 'complete') { 52 | return createSseCompleteMessage(); 53 | } 54 | return createSseMessage('message', value); 55 | }) 56 | .join('') 57 | ) 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /src/__api-mocks__/server.ts: -------------------------------------------------------------------------------- 1 | import { setupServer } from 'msw/node'; 2 | export { rest } from 'msw'; 3 | 4 | import { handlers } from './handlers'; 5 | 6 | export const server = setupServer(...handlers); 7 | -------------------------------------------------------------------------------- /src/__tests__/subscription-storage.spec.ts: -------------------------------------------------------------------------------- 1 | import { Subject } from 'rxjs'; 2 | import { QueryClient, QueryCache } from 'react-query'; 3 | 4 | import { 5 | storeSubscription, 6 | cleanupSubscription, 7 | } from '../subscription-storage'; 8 | 9 | describe('subscription storage', () => { 10 | let queryCache: QueryCache; 11 | let queryClient: QueryClient; 12 | 13 | beforeEach(() => { 14 | queryCache = new QueryCache(); 15 | queryClient = new QueryClient({ 16 | queryCache, 17 | defaultOptions: { 18 | queries: { retry: false }, 19 | }, 20 | }); 21 | }); 22 | 23 | afterEach(() => { 24 | queryCache.clear(); 25 | }); 26 | 27 | function subscriptionFactory(observerFn?: (value: T) => void) { 28 | const subject = new Subject(); 29 | const subscription = subject.asObservable().subscribe(observerFn); 30 | return { 31 | subscription, 32 | next: (value: T) => subject.next(value), 33 | observerFn, 34 | }; 35 | } 36 | 37 | test('store and cleanup subscription', () => { 38 | const observerFn = vi.fn(); 39 | const { subscription, next } = subscriptionFactory(observerFn); 40 | 41 | storeSubscription(queryClient, 'test', subscription); 42 | 43 | next('value-1'); 44 | expect(observerFn).toHaveBeenCalledTimes(1); 45 | expect(observerFn).toHaveBeenCalledWith('value-1'); 46 | observerFn.mockClear(); 47 | 48 | cleanupSubscription(queryClient, 'test'); 49 | 50 | next('value-2'); 51 | expect(observerFn).not.toHaveBeenCalled(); 52 | }); 53 | 54 | test('only unsubscribes the specified subscription', () => { 55 | const observerAFn = vi.fn(); 56 | const { subscription: subscriptionA, next: nextA } = 57 | subscriptionFactory(observerAFn); 58 | const observerBFn = vi.fn(); 59 | const { subscription: subscriptionB, next: nextB } = 60 | subscriptionFactory(observerBFn); 61 | 62 | storeSubscription(queryClient, 'testA', subscriptionA); 63 | storeSubscription(queryClient, 'testB', subscriptionB); 64 | 65 | nextA('A1'); 66 | expect(observerAFn).toHaveBeenCalledTimes(1); 67 | expect(observerAFn).toHaveBeenCalledWith('A1'); 68 | observerAFn.mockClear(); 69 | nextB('B1'); 70 | expect(observerBFn).toHaveBeenCalledTimes(1); 71 | expect(observerBFn).toHaveBeenCalledWith('B1'); 72 | observerBFn.mockClear(); 73 | 74 | cleanupSubscription(queryClient, 'testA'); 75 | 76 | nextA('A2'); 77 | expect(observerAFn).not.toHaveBeenCalled(); 78 | nextB('B2'); 79 | expect(observerBFn).toHaveBeenCalledTimes(1); 80 | expect(observerBFn).toHaveBeenCalledWith('B2'); 81 | }); 82 | 83 | it('should not fail when key does not exist', () => { 84 | expect(() => 85 | cleanupSubscription(queryClient, 'test-non-existing') 86 | ).not.toThrow(); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /src/__tests__/use-infinite-subscription.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { renderHook } from '@testing-library/react-hooks'; 3 | import { Subject } from 'rxjs'; 4 | import { finalize, filter } from 'rxjs/operators'; 5 | import { QueryClient, QueryClientProvider, QueryCache } from 'react-query'; 6 | 7 | import { useInfiniteSubscription } from '../use-infinite-subscription'; 8 | 9 | describe('useInfiniteSubscription', () => { 10 | let queryCache: QueryCache; 11 | let queryClient: QueryClient; 12 | 13 | beforeEach(() => { 14 | queryCache = new QueryCache(); 15 | queryClient = new QueryClient({ 16 | queryCache, 17 | defaultOptions: { 18 | queries: { retry: false }, 19 | }, 20 | }); 21 | }); 22 | 23 | afterEach(() => { 24 | queryCache.clear(); 25 | }); 26 | 27 | function Wrapper({ 28 | children, 29 | }: React.PropsWithChildren>) { 30 | return ( 31 | {children} 32 | ); 33 | } 34 | 35 | function subscriptionFnFactory() { 36 | const testSubject = new Subject(); 37 | const finalizeFn = vi.fn(); 38 | const subscriptionFn = vi.fn(({ pageParam = 0 }) => 39 | testSubject.asObservable().pipe( 40 | filter((data) => data >= pageParam * 10 && data < (pageParam + 1) * 10), 41 | finalize(finalizeFn) 42 | ) 43 | ); 44 | return { 45 | subscriptionFn, 46 | finalizeFn, 47 | next: (value: number) => testSubject.next(value), 48 | error: (error: Error) => testSubject.error(error), 49 | }; 50 | } 51 | 52 | const testSubscriptionKey = ['test-subscription-key']; 53 | 54 | function mapToPages(data: T) { 55 | return { pageParams: [undefined], pages: [data] }; 56 | } 57 | 58 | it('should not re-subscribe when re-rendered', () => { 59 | const { subscriptionFn } = subscriptionFnFactory(); 60 | const { rerender } = renderHook( 61 | () => useInfiniteSubscription(['non-primitive-key'], subscriptionFn), 62 | { wrapper: Wrapper } 63 | ); 64 | expect(subscriptionFn).toHaveBeenCalledTimes(1); 65 | subscriptionFn.mockClear(); 66 | 67 | rerender(); 68 | expect(subscriptionFn).toHaveBeenCalledTimes(0); 69 | }); 70 | 71 | describe('options', () => { 72 | describe('getNextPageParam', () => { 73 | test('fetching next page', async () => { 74 | const { subscriptionFn, next } = subscriptionFnFactory(); 75 | 76 | const getNextPageParam = vi.fn( 77 | (_lastPage, allPages) => allPages.length 78 | ); 79 | const { result, waitForNextUpdate } = renderHook( 80 | () => 81 | useInfiniteSubscription(testSubscriptionKey, subscriptionFn, { 82 | getNextPageParam, 83 | }), 84 | { wrapper: Wrapper } 85 | ); 86 | expect(result.current.data).toBeUndefined(); 87 | expect(result.current.status).toBe('loading'); 88 | expect(result.current.isFetchingNextPage).toBe(false); 89 | expect(getNextPageParam).not.toHaveBeenCalled(); 90 | 91 | next(1); 92 | await waitForNextUpdate(); 93 | expect(result.current.status).toBe('success'); 94 | expect(result.current.data).toEqual(mapToPages(1)); 95 | 96 | expect(result.current.hasNextPage).toBe(true); 97 | 98 | result.current.fetchNextPage(); 99 | expect(getNextPageParam).toHaveBeenCalledWith(1, [1]); 100 | 101 | await waitForNextUpdate(); 102 | expect(result.current.isFetchingNextPage).toBe(true); 103 | expect(result.current.data).toEqual(mapToPages(1)); 104 | expect(result.current.status).toBe('success'); 105 | 106 | next(12); 107 | await waitForNextUpdate(); 108 | expect(result.current.isFetchingNextPage).toBe(false); 109 | expect(result.current.status).toBe('success'); 110 | expect(result.current.data).toEqual({ 111 | pageParams: [undefined, 1], 112 | pages: [1, 12], 113 | }); 114 | 115 | expect(result.current.hasNextPage).toBe(true); 116 | 117 | result.current.fetchNextPage(); 118 | expect(getNextPageParam).toHaveBeenCalledWith(12, [1, 12]); 119 | 120 | await waitForNextUpdate(); 121 | expect(result.current.isFetchingNextPage).toBe(true); 122 | 123 | next(23); 124 | await waitForNextUpdate(); 125 | expect(result.current.isFetchingNextPage).toBe(false); 126 | expect(result.current.status).toBe('success'); 127 | expect(result.current.data).toEqual({ 128 | pageParams: [undefined, 1, 2], 129 | pages: [1, 12, 23], 130 | }); 131 | }); 132 | 133 | it('should have no next page', async () => { 134 | const { subscriptionFn, next } = subscriptionFnFactory(); 135 | 136 | const getNextPageParam = vi.fn(() => undefined); 137 | const { result, waitForNextUpdate } = renderHook( 138 | () => 139 | useInfiniteSubscription(testSubscriptionKey, subscriptionFn, { 140 | getNextPageParam, 141 | }), 142 | { wrapper: Wrapper } 143 | ); 144 | expect(result.current.data).toBeUndefined(); 145 | expect(result.current.status).toBe('loading'); 146 | expect(getNextPageParam).not.toHaveBeenCalled(); 147 | 148 | next(1); 149 | await waitForNextUpdate(); 150 | expect(result.current.status).toBe('success'); 151 | expect(result.current.data).toEqual(mapToPages(1)); 152 | 153 | expect(result.current.hasNextPage).toBe(false); 154 | 155 | result.current.fetchNextPage(); 156 | expect(getNextPageParam).toHaveBeenCalledWith(1, [1]); 157 | 158 | await waitForNextUpdate(); 159 | expect(result.current.isFetchingNextPage).toBe(false); 160 | expect(result.current.data).toEqual(mapToPages(1)); 161 | expect(result.current.status).toBe('success'); 162 | 163 | next(2); 164 | await waitForNextUpdate(); 165 | expect(result.current.isFetchingNextPage).toBe(false); 166 | expect(result.current.status).toBe('success'); 167 | expect(result.current.data).toEqual(mapToPages(2)); 168 | }); 169 | 170 | test('updating previously subscribed pages', async () => { 171 | const { subscriptionFn, next } = subscriptionFnFactory(); 172 | 173 | const getNextPageParam = vi.fn( 174 | (_lastPage, allPages) => allPages.length 175 | ); 176 | const { result, waitForNextUpdate } = renderHook( 177 | () => 178 | useInfiniteSubscription(testSubscriptionKey, subscriptionFn, { 179 | getNextPageParam, 180 | }), 181 | { wrapper: Wrapper } 182 | ); 183 | 184 | next(1); 185 | await waitForNextUpdate(); 186 | expect(result.current.status).toBe('success'); 187 | expect(result.current.data).toEqual(mapToPages(1)); 188 | 189 | expect(result.current.hasNextPage).toBe(true); 190 | 191 | result.current.fetchNextPage(); 192 | await waitForNextUpdate(); 193 | expect(result.current.isFetchingNextPage).toBe(true); 194 | expect(result.current.data).toEqual(mapToPages(1)); 195 | expect(result.current.status).toBe('success'); 196 | 197 | next(12); 198 | await waitForNextUpdate(); 199 | expect(result.current.isFetchingNextPage).toBe(false); 200 | expect(result.current.status).toBe('success'); 201 | expect(result.current.data).toEqual({ 202 | pageParams: [undefined, 1], 203 | pages: [1, 12], 204 | }); 205 | 206 | next(2); 207 | await waitForNextUpdate(); 208 | expect(result.current.isFetchingNextPage).toBe(false); 209 | expect(result.current.status).toBe('success'); 210 | expect(result.current.data).toEqual({ 211 | pageParams: [undefined, 1], 212 | pages: [2, 12], 213 | }); 214 | 215 | next(13); 216 | await waitForNextUpdate(); 217 | expect(result.current.isFetchingNextPage).toBe(false); 218 | expect(result.current.status).toBe('success'); 219 | expect(result.current.data).toEqual({ 220 | pageParams: [undefined, 1], 221 | pages: [2, 13], 222 | }); 223 | }); 224 | }); 225 | 226 | describe('getPreviousPageParam', () => { 227 | test('fetching previous page', async () => { 228 | const { subscriptionFn, next } = subscriptionFnFactory(); 229 | 230 | const getPreviousPageParam = vi.fn( 231 | (_lastPage, allPages) => allPages.length 232 | ); 233 | const { result, waitForNextUpdate } = renderHook( 234 | () => 235 | useInfiniteSubscription(testSubscriptionKey, subscriptionFn, { 236 | getPreviousPageParam, 237 | }), 238 | { wrapper: Wrapper } 239 | ); 240 | expect(result.current.data).toBeUndefined(); 241 | expect(result.current.status).toBe('loading'); 242 | expect(result.current.isFetchingPreviousPage).toBe(false); 243 | expect(getPreviousPageParam).not.toHaveBeenCalled(); 244 | 245 | next(1); 246 | await waitForNextUpdate(); 247 | expect(result.current.status).toBe('success'); 248 | expect(result.current.data).toEqual(mapToPages(1)); 249 | 250 | expect(result.current.hasPreviousPage).toBe(true); 251 | 252 | result.current.fetchPreviousPage(); 253 | expect(getPreviousPageParam).toHaveBeenCalledWith(1, [1]); 254 | 255 | await waitForNextUpdate(); 256 | expect(result.current.isFetchingPreviousPage).toBe(true); 257 | expect(result.current.data).toEqual(mapToPages(1)); 258 | expect(result.current.status).toBe('success'); 259 | 260 | next(12); 261 | await waitForNextUpdate(); 262 | expect(result.current.isFetchingPreviousPage).toBe(false); 263 | expect(result.current.status).toBe('success'); 264 | expect(result.current.data).toEqual({ 265 | pageParams: [1, undefined], 266 | pages: [12, 1], 267 | }); 268 | 269 | expect(result.current.hasPreviousPage).toBe(true); 270 | 271 | result.current.fetchPreviousPage(); 272 | expect(getPreviousPageParam).toHaveBeenCalledWith(12, [12, 1]); 273 | 274 | await waitForNextUpdate(); 275 | expect(result.current.isFetchingPreviousPage).toBe(true); 276 | 277 | next(23); 278 | await waitForNextUpdate(); 279 | expect(result.current.isFetchingPreviousPage).toBe(false); 280 | expect(result.current.status).toBe('success'); 281 | expect(result.current.data).toEqual({ 282 | pageParams: [2, 1, undefined], 283 | pages: [23, 12, 1], 284 | }); 285 | }); 286 | 287 | it('should have no previous page', async () => { 288 | const { subscriptionFn, next } = subscriptionFnFactory(); 289 | 290 | const getPreviousPageParam = vi.fn(() => undefined); 291 | const { result, waitForNextUpdate } = renderHook( 292 | () => 293 | useInfiniteSubscription(testSubscriptionKey, subscriptionFn, { 294 | getPreviousPageParam, 295 | }), 296 | { wrapper: Wrapper } 297 | ); 298 | expect(result.current.data).toBeUndefined(); 299 | expect(result.current.status).toBe('loading'); 300 | expect(getPreviousPageParam).not.toHaveBeenCalled(); 301 | 302 | next(1); 303 | await waitForNextUpdate(); 304 | expect(result.current.status).toBe('success'); 305 | expect(result.current.data).toEqual(mapToPages(1)); 306 | 307 | expect(result.current.hasPreviousPage).toBe(false); 308 | 309 | result.current.fetchPreviousPage(); 310 | expect(getPreviousPageParam).toHaveBeenCalledWith(1, [1]); 311 | 312 | await waitForNextUpdate(); 313 | expect(result.current.isFetchingPreviousPage).toBe(false); 314 | expect(result.current.data).toEqual(mapToPages(1)); 315 | expect(result.current.status).toBe('success'); 316 | 317 | next(2); 318 | await waitForNextUpdate(); 319 | expect(result.current.isFetchingPreviousPage).toBe(false); 320 | expect(result.current.status).toBe('success'); 321 | expect(result.current.data).toEqual(mapToPages(2)); 322 | }); 323 | 324 | test('updating previously subscribed pages', async () => { 325 | const { subscriptionFn, next } = subscriptionFnFactory(); 326 | 327 | const getPreviousPageParam = vi.fn( 328 | (_lastPage, allPages) => allPages.length 329 | ); 330 | const { result, waitForNextUpdate } = renderHook( 331 | () => 332 | useInfiniteSubscription(testSubscriptionKey, subscriptionFn, { 333 | getPreviousPageParam, 334 | }), 335 | { wrapper: Wrapper } 336 | ); 337 | 338 | next(1); 339 | await waitForNextUpdate(); 340 | expect(result.current.status).toBe('success'); 341 | expect(result.current.data).toEqual(mapToPages(1)); 342 | 343 | expect(result.current.hasPreviousPage).toBe(true); 344 | 345 | result.current.fetchPreviousPage(); 346 | await waitForNextUpdate(); 347 | expect(result.current.isFetchingPreviousPage).toBe(true); 348 | expect(result.current.data).toEqual(mapToPages(1)); 349 | expect(result.current.status).toBe('success'); 350 | 351 | next(12); 352 | await waitForNextUpdate(); 353 | expect(result.current.isFetchingPreviousPage).toBe(false); 354 | expect(result.current.status).toBe('success'); 355 | expect(result.current.data).toEqual({ 356 | pageParams: [1, undefined], 357 | pages: [12, 1], 358 | }); 359 | 360 | next(2); 361 | await waitForNextUpdate(); 362 | expect(result.current.isFetchingPreviousPage).toBe(false); 363 | expect(result.current.status).toBe('success'); 364 | expect(result.current.data).toEqual({ 365 | pageParams: [1, undefined], 366 | pages: [12, 2], 367 | }); 368 | 369 | next(13); 370 | await waitForNextUpdate(); 371 | expect(result.current.isFetchingPreviousPage).toBe(false); 372 | expect(result.current.status).toBe('success'); 373 | expect(result.current.data).toEqual({ 374 | pageParams: [1, undefined], 375 | pages: [13, 2], 376 | }); 377 | }); 378 | }); 379 | }); 380 | }); 381 | -------------------------------------------------------------------------------- /src/__tests__/use-subscription.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { renderHook, RenderHookResult } from '@testing-library/react-hooks'; 3 | import { interval, fromEvent } from 'rxjs'; 4 | import { map, finalize, takeUntil } from 'rxjs/operators'; 5 | import { QueryClient, QueryClientProvider, QueryCache } from 'react-query'; 6 | 7 | import { 8 | useSubscription, 9 | UseSubscriptionOptions, 10 | UseSubscriptionResult, 11 | } from '../use-subscription'; 12 | 13 | describe('useSubscription', () => { 14 | let queryCache: QueryCache; 15 | let queryClient: QueryClient; 16 | 17 | beforeEach(() => { 18 | queryCache = new QueryCache(); 19 | queryClient = new QueryClient({ 20 | queryCache, 21 | defaultOptions: { 22 | queries: { retry: false }, 23 | }, 24 | }); 25 | }); 26 | 27 | afterEach(() => { 28 | queryCache.clear(); 29 | }); 30 | 31 | function Wrapper({ 32 | children, 33 | }: UseSubscriptionOptions & { children: React.ReactNode }) { 34 | return ( 35 | {children} 36 | ); 37 | } 38 | 39 | const testInterval = 10; 40 | const finalizeFn = vi.fn(); 41 | const test$ = interval(testInterval).pipe(finalize(finalizeFn)); 42 | const testSubscriptionFn = vi.fn(() => test$); 43 | 44 | const testSubscriptionKey = ['test-subscription-key']; 45 | 46 | afterEach(() => { 47 | testSubscriptionFn.mockClear(); 48 | finalizeFn.mockClear(); 49 | }); 50 | 51 | it('should have correct status', async () => { 52 | const { result, waitForNextUpdate } = renderHook( 53 | () => useSubscription(testSubscriptionKey, testSubscriptionFn), 54 | { wrapper: Wrapper } 55 | ); 56 | expect(result.current.status).toBe('loading'); 57 | 58 | await waitForNextUpdate(); 59 | expect(result.current.status).toBe('success'); 60 | 61 | await waitForNextUpdate(); 62 | expect(result.current.status).toBe('success'); 63 | }); 64 | 65 | it('should have correct data', async () => { 66 | const { result, waitForNextUpdate } = renderHook( 67 | () => useSubscription(testSubscriptionKey, testSubscriptionFn), 68 | { wrapper: Wrapper } 69 | ); 70 | expect(result.current.data).toBeUndefined(); 71 | 72 | await waitForNextUpdate(); 73 | expect(result.current.data).toBe(0); 74 | 75 | await waitForNextUpdate(); 76 | expect(result.current.data).toBe(1); 77 | }); 78 | 79 | test('subscription data', async () => { 80 | const onData = vi.fn(); 81 | const { result, waitForNextUpdate } = renderHook( 82 | () => 83 | useSubscription(testSubscriptionKey, testSubscriptionFn, { 84 | onData, 85 | }), 86 | { wrapper: Wrapper } 87 | ); 88 | expect(result.current.data).toBeUndefined(); 89 | 90 | await waitForNextUpdate(); 91 | expect(result.current.data).toBe(0); 92 | expect(onData).toHaveBeenCalledTimes(1); 93 | expect(onData).toHaveBeenCalledWith(0); 94 | 95 | await waitForNextUpdate(); 96 | expect(result.current.data).toBe(1); 97 | expect(onData).toHaveBeenCalledTimes(2); 98 | expect(onData).toHaveBeenCalledWith(1); 99 | }); 100 | 101 | test('subscription error', async () => { 102 | const testErrorSubscriptionFn = () => { 103 | throw new Error('Test Error'); 104 | }; 105 | const onError = vi.fn(); 106 | 107 | const { result, waitForNextUpdate } = renderHook( 108 | () => 109 | useSubscription(testSubscriptionKey, testErrorSubscriptionFn, { 110 | onError, 111 | }), 112 | { wrapper: Wrapper } 113 | ); 114 | expect(result.current.status).toBe('loading'); 115 | expect(result.current.data).toBeUndefined(); 116 | 117 | await waitForNextUpdate(); 118 | expect(result.current.status).toBe('error'); 119 | expect(result.current.error).toEqual(new Error('Test Error')); 120 | expect(result.current.failureCount).toBe(1); 121 | expect(result.current.data).toBeUndefined(); 122 | expect(onError).toHaveBeenCalledTimes(1); 123 | expect(onError).toHaveBeenCalledWith(new Error('Test Error')); 124 | }); 125 | 126 | test('emitted error', async () => { 127 | const testErrorSubscriptionFn = vi.fn(() => 128 | interval(testInterval).pipe( 129 | map((n) => { 130 | if (n === 2) { 131 | throw new Error('Test Error'); 132 | } 133 | return n; 134 | }) 135 | ) 136 | ); 137 | const onError = vi.fn(); 138 | 139 | const { result, waitForNextUpdate } = renderHook( 140 | () => 141 | useSubscription(testSubscriptionKey, testErrorSubscriptionFn, { 142 | onError, 143 | }), 144 | { wrapper: Wrapper } 145 | ); 146 | expect(result.current.status).toBe('loading'); 147 | expect(result.current.data).toBeUndefined(); 148 | expect(testErrorSubscriptionFn).toHaveBeenCalledTimes(1); 149 | testErrorSubscriptionFn.mockClear(); 150 | 151 | await waitForNextUpdate(); 152 | expect(result.current.status).toBe('success'); 153 | expect(result.current.data).toBe(0); 154 | 155 | await waitForNextUpdate(); 156 | expect(result.current.status).toBe('success'); 157 | expect(result.current.data).toBe(1); 158 | 159 | await waitForNextUpdate(); 160 | expect(result.current.status).toBe('error'); 161 | expect(result.current.error).toEqual(new Error('Test Error')); 162 | expect(result.current.failureCount).toBe(1); 163 | expect(result.current.data).toBe(1); 164 | expect(onError).toHaveBeenCalledTimes(1); 165 | expect(onError).toHaveBeenCalledWith(new Error('Test Error')); 166 | 167 | expect(testErrorSubscriptionFn).not.toHaveBeenCalled(); 168 | }); 169 | 170 | it('should subscribe on mount', async () => { 171 | const { waitForNextUpdate, unmount } = renderHook( 172 | () => useSubscription(testSubscriptionKey, testSubscriptionFn), 173 | { wrapper: Wrapper } 174 | ); 175 | expect(testSubscriptionFn).toHaveBeenCalledTimes(1); 176 | testSubscriptionFn.mockClear(); 177 | 178 | await waitForNextUpdate(); 179 | expect(testSubscriptionFn).toHaveBeenCalledTimes(0); 180 | 181 | unmount(); 182 | expect(testSubscriptionFn).toHaveBeenCalledTimes(0); 183 | }); 184 | 185 | it('should unsubscribe on unmount', async () => { 186 | const { waitForNextUpdate, unmount } = renderHook( 187 | () => useSubscription(testSubscriptionKey, testSubscriptionFn), 188 | { wrapper: Wrapper } 189 | ); 190 | expect(finalizeFn).toHaveBeenCalledTimes(0); 191 | 192 | await waitForNextUpdate(); 193 | expect(finalizeFn).toHaveBeenCalledTimes(0); 194 | 195 | unmount(); 196 | expect(finalizeFn).toHaveBeenCalledTimes(1); 197 | }); 198 | 199 | it('should not re-subscribe when re-rendered', () => { 200 | const { rerender } = renderHook( 201 | () => useSubscription(['non-primitive-key'], testSubscriptionFn), 202 | { wrapper: Wrapper } 203 | ); 204 | expect(testSubscriptionFn).toHaveBeenCalledTimes(1); 205 | testSubscriptionFn.mockClear(); 206 | 207 | rerender(); 208 | expect(testSubscriptionFn).toHaveBeenCalledTimes(0); 209 | }); 210 | 211 | test('re-subscribe when mounted/unmounted/mounted', async () => { 212 | const { result, waitForNextUpdate, unmount, rerender } = renderHook( 213 | () => useSubscription(testSubscriptionKey, testSubscriptionFn), 214 | { wrapper: Wrapper } 215 | ); 216 | expect(result.current.status).toBe('loading'); 217 | 218 | unmount(); 219 | expect(testSubscriptionFn).toHaveBeenCalledTimes(1); 220 | testSubscriptionFn.mockClear(); 221 | 222 | rerender(); 223 | expect(result.current.status).toBe('loading'); 224 | 225 | await waitForNextUpdate(); 226 | expect(result.current.status).toBe('success'); 227 | expect(result.current.data).toBe(0); 228 | 229 | await waitForNextUpdate(); 230 | expect(result.current.status).toBe('success'); 231 | expect(result.current.data).toBe(1); 232 | 233 | expect(testSubscriptionFn).toHaveBeenCalledTimes(1); 234 | }); 235 | 236 | describe('multiple components subscribed to a different subscription key', () => { 237 | let firstHookRender: RenderHookResult< 238 | UseSubscriptionOptions, 239 | UseSubscriptionResult 240 | >; 241 | 242 | beforeEach(() => { 243 | firstHookRender = renderHook( 244 | (options: UseSubscriptionOptions) => 245 | useSubscription( 246 | ['first-subscription-key'], 247 | testSubscriptionFn, 248 | options 249 | ), 250 | { wrapper: Wrapper } 251 | ); 252 | }); 253 | 254 | afterEach(() => { 255 | firstHookRender.unmount(); 256 | }); 257 | 258 | test('status and data when rendered the same time as first component', async () => { 259 | const { result, waitForNextUpdate } = renderHook( 260 | () => useSubscription(testSubscriptionKey, testSubscriptionFn), 261 | { wrapper: Wrapper } 262 | ); 263 | expect(result.current.status).toBe('loading'); 264 | expect(result.current.data).toBeUndefined(); 265 | 266 | await waitForNextUpdate(); 267 | expect(result.current.status).toBe('success'); 268 | expect(result.current.data).toBe(0); 269 | 270 | await waitForNextUpdate(); 271 | expect(result.current.status).toBe('success'); 272 | expect(result.current.data).toBe(1); 273 | }); 274 | 275 | test('status and data when rendered after the first component', async () => { 276 | await firstHookRender.waitFor(() => { 277 | expect(firstHookRender.result.current.status).toBe('success'); 278 | }); 279 | 280 | const { result, waitForNextUpdate } = renderHook( 281 | () => useSubscription(testSubscriptionKey, testSubscriptionFn), 282 | { wrapper: Wrapper } 283 | ); 284 | expect(result.current.status).toBe('loading'); 285 | expect(result.current.data).toBeUndefined(); 286 | 287 | await waitForNextUpdate(); 288 | expect(result.current.status).toBe('success'); 289 | expect(result.current.data).toBe(0); 290 | 291 | await waitForNextUpdate(); 292 | expect(result.current.status).toBe('success'); 293 | expect(result.current.data).toBe(1); 294 | }); 295 | 296 | test('status and data after first component unmount', async () => { 297 | await firstHookRender.waitFor(() => { 298 | expect(firstHookRender.result.current.status).toBe('success'); 299 | }); 300 | 301 | const { result, waitFor, waitForNextUpdate } = renderHook( 302 | () => useSubscription(testSubscriptionKey, testSubscriptionFn), 303 | { wrapper: Wrapper } 304 | ); 305 | 306 | await waitFor(() => { 307 | expect(result.current.status).toBe('success'); 308 | expect(result.current.data).toBe(1); 309 | }); 310 | 311 | firstHookRender.unmount(); 312 | 313 | await waitForNextUpdate(); 314 | expect(result.current.status).toBe('success'); 315 | expect(result.current.data).toBe(2); 316 | }); 317 | 318 | test('status and data after second component unmount', async () => { 319 | await firstHookRender.waitFor(() => { 320 | expect(firstHookRender.result.current.status).toBe('success'); 321 | }); 322 | 323 | const { waitFor, unmount } = renderHook( 324 | () => useSubscription(testSubscriptionKey, testSubscriptionFn), 325 | { wrapper: Wrapper } 326 | ); 327 | 328 | await waitFor(() => { 329 | expect(firstHookRender.result.current.status).toBe('success'); 330 | expect(firstHookRender.result.current.data).toBe(1); 331 | }); 332 | 333 | unmount(); 334 | 335 | expect(firstHookRender.result.current.status).toBe('success'); 336 | expect(firstHookRender.result.current.data).toBe(1); 337 | 338 | await firstHookRender.waitForNextUpdate(); 339 | expect(firstHookRender.result.current.status).toBe('success'); 340 | expect(firstHookRender.result.current.data).toBe(2); 341 | }); 342 | 343 | it('should subscribe for both components', async () => { 344 | expect(testSubscriptionFn).toHaveBeenCalledTimes(1); 345 | testSubscriptionFn.mockClear(); 346 | 347 | const { waitForNextUpdate } = renderHook( 348 | () => useSubscription(testSubscriptionKey, testSubscriptionFn), 349 | { wrapper: Wrapper } 350 | ); 351 | await waitForNextUpdate(); 352 | expect(testSubscriptionFn).toHaveBeenCalledTimes(1); 353 | }); 354 | 355 | it('should only unsubscribe on the second component unmount', async () => { 356 | expect(finalizeFn).toHaveBeenCalledTimes(0); 357 | 358 | const { waitForNextUpdate, unmount } = renderHook( 359 | () => useSubscription(testSubscriptionKey, testSubscriptionFn), 360 | { wrapper: Wrapper } 361 | ); 362 | expect(finalizeFn).toHaveBeenCalledTimes(0); 363 | 364 | await waitForNextUpdate(); 365 | expect(finalizeFn).toHaveBeenCalledTimes(0); 366 | 367 | unmount(); 368 | expect(finalizeFn).toHaveBeenCalledTimes(1); 369 | finalizeFn.mockClear(); 370 | 371 | firstHookRender.unmount(); 372 | expect(finalizeFn).toHaveBeenCalledTimes(1); 373 | }); 374 | 375 | it('should not re-subscribe when the first component unmount', async () => { 376 | expect(testSubscriptionFn).toHaveBeenCalledTimes(1); 377 | testSubscriptionFn.mockClear(); 378 | 379 | const { waitForNextUpdate, unmount } = renderHook( 380 | () => useSubscription(testSubscriptionKey, testSubscriptionFn), 381 | { wrapper: Wrapper } 382 | ); 383 | await waitForNextUpdate(); 384 | expect(testSubscriptionFn).toHaveBeenCalledTimes(1); 385 | testSubscriptionFn.mockClear(); 386 | 387 | firstHookRender.unmount(); 388 | expect(finalizeFn).toHaveBeenCalledTimes(1); 389 | finalizeFn.mockClear(); 390 | expect(testSubscriptionFn).toHaveBeenCalledTimes(0); 391 | 392 | unmount(); 393 | expect(finalizeFn).toHaveBeenCalledTimes(1); 394 | }); 395 | }); 396 | 397 | describe('multiple components subscribed to the same subscription key', () => { 398 | let firstHookRender: RenderHookResult< 399 | UseSubscriptionOptions, 400 | UseSubscriptionResult 401 | >; 402 | 403 | beforeEach(() => { 404 | firstHookRender = renderHook( 405 | (options: UseSubscriptionOptions) => 406 | useSubscription(testSubscriptionKey, testSubscriptionFn, options), 407 | { wrapper: Wrapper } 408 | ); 409 | }); 410 | 411 | afterEach(() => { 412 | firstHookRender.unmount(); 413 | }); 414 | 415 | test('status and data when rendered the same time as first component', async () => { 416 | const { result, waitForNextUpdate } = renderHook( 417 | () => useSubscription(testSubscriptionKey, testSubscriptionFn), 418 | { wrapper: Wrapper } 419 | ); 420 | expect(result.current.status).toBe('loading'); 421 | expect(result.current.data).toBeUndefined(); 422 | 423 | await waitForNextUpdate(); 424 | expect(result.current.status).toBe('success'); 425 | expect(result.current.data).toBe(0); 426 | 427 | await waitForNextUpdate(); 428 | expect(result.current.status).toBe('success'); 429 | expect(result.current.data).toBe(1); 430 | }); 431 | 432 | test('status and data when rendered after the first component', async () => { 433 | await firstHookRender.waitFor(() => { 434 | expect(firstHookRender.result.current.status).toBe('success'); 435 | }); 436 | 437 | const { result, waitForNextUpdate } = renderHook( 438 | () => useSubscription(testSubscriptionKey, testSubscriptionFn), 439 | { wrapper: Wrapper } 440 | ); 441 | expect(result.current.status).toBe('success'); 442 | expect(result.current.data).toBe(0); 443 | 444 | await waitForNextUpdate(); 445 | expect(result.current.status).toBe('success'); 446 | expect(result.current.data).toBe(1); 447 | }); 448 | 449 | test('status and data after first component unmount', async () => { 450 | await firstHookRender.waitFor(() => { 451 | expect(firstHookRender.result.current.status).toBe('success'); 452 | }); 453 | 454 | const { result, waitFor, waitForNextUpdate } = renderHook( 455 | () => useSubscription(testSubscriptionKey, testSubscriptionFn), 456 | { wrapper: Wrapper } 457 | ); 458 | 459 | await waitFor(() => { 460 | expect(result.current.status).toBe('success'); 461 | expect(result.current.data).toBe(1); 462 | }); 463 | 464 | firstHookRender.unmount(); 465 | 466 | expect(result.current.status).toBe('success'); 467 | expect(result.current.data).toBe(1); 468 | 469 | await waitForNextUpdate(); 470 | expect(result.current.status).toBe('success'); 471 | expect(result.current.data).toBe(2); 472 | }); 473 | 474 | test('status and data after second component unmount', async () => { 475 | await firstHookRender.waitFor(() => { 476 | expect(firstHookRender.result.current.status).toBe('success'); 477 | }); 478 | 479 | const { result, waitFor, unmount } = renderHook( 480 | () => useSubscription(testSubscriptionKey, testSubscriptionFn), 481 | { wrapper: Wrapper } 482 | ); 483 | 484 | await waitFor(() => { 485 | expect(result.current.status).toBe('success'); 486 | expect(result.current.data).toBe(1); 487 | }); 488 | 489 | unmount(); 490 | 491 | await firstHookRender.waitForNextUpdate(); 492 | expect(firstHookRender.result.current.status).toBe('success'); 493 | expect(firstHookRender.result.current.data).toBe(2); 494 | }); 495 | 496 | it('should subscribe only on the first component mount', async () => { 497 | expect(testSubscriptionFn).toHaveBeenCalledTimes(1); 498 | testSubscriptionFn.mockClear(); 499 | 500 | const { waitForNextUpdate } = renderHook( 501 | () => useSubscription(testSubscriptionKey, testSubscriptionFn), 502 | { wrapper: Wrapper } 503 | ); 504 | await waitForNextUpdate(); 505 | expect(testSubscriptionFn).toHaveBeenCalledTimes(0); 506 | }); 507 | 508 | it('should only unsubscribe on the second component unmount', async () => { 509 | expect(finalizeFn).toHaveBeenCalledTimes(0); 510 | 511 | const { waitForNextUpdate, unmount } = renderHook( 512 | () => useSubscription(testSubscriptionKey, testSubscriptionFn), 513 | { wrapper: Wrapper } 514 | ); 515 | expect(finalizeFn).toHaveBeenCalledTimes(0); 516 | 517 | await waitForNextUpdate(); 518 | expect(finalizeFn).toHaveBeenCalledTimes(0); 519 | 520 | unmount(); 521 | expect(finalizeFn).toHaveBeenCalledTimes(0); 522 | 523 | firstHookRender.unmount(); 524 | expect(finalizeFn).toHaveBeenCalledTimes(1); 525 | }); 526 | 527 | it('should not re-subscribe when the first component unmount', async () => { 528 | expect(testSubscriptionFn).toHaveBeenCalledTimes(1); 529 | testSubscriptionFn.mockClear(); 530 | 531 | const { waitForNextUpdate, unmount } = renderHook( 532 | () => useSubscription(testSubscriptionKey, testSubscriptionFn), 533 | { wrapper: Wrapper } 534 | ); 535 | await waitForNextUpdate(); 536 | 537 | firstHookRender.unmount(); 538 | expect(finalizeFn).toHaveBeenCalledTimes(0); 539 | expect(testSubscriptionFn).toHaveBeenCalledTimes(0); 540 | 541 | unmount(); 542 | expect(finalizeFn).toHaveBeenCalledTimes(1); 543 | }); 544 | }); 545 | 546 | describe('queryFn', () => { 547 | describe('signal', () => { 548 | it('should cancel the subscription', async () => { 549 | const finalizeFn = vi.fn(); 550 | const testSubscriptionFn = vi.fn(({ signal }) => 551 | interval(testInterval).pipe( 552 | takeUntil(fromEvent(signal, 'abort')), 553 | finalize(finalizeFn) 554 | ) 555 | ); 556 | const { unmount } = renderHook( 557 | () => useSubscription(testSubscriptionKey, testSubscriptionFn), 558 | { wrapper: Wrapper } 559 | ); 560 | expect(finalizeFn).not.toHaveBeenCalled(); 561 | 562 | unmount(); 563 | expect(finalizeFn).toHaveBeenCalled(); 564 | }); 565 | }); 566 | }); 567 | 568 | describe('options', () => { 569 | describe('enabled', () => { 570 | it('should be idle while enabled = false', async () => { 571 | const { result } = renderHook( 572 | () => 573 | useSubscription(testSubscriptionKey, testSubscriptionFn, { 574 | enabled: false, 575 | }), 576 | { wrapper: Wrapper } 577 | ); 578 | expect(result.current.status).toBe('idle'); 579 | expect(result.current.data).toBeUndefined(); 580 | 581 | // Wait for the test interval amount of time. 582 | // The data should not be populated as enabled = false. 583 | await new Promise((resolve) => setTimeout(resolve, 2 * testInterval)); 584 | 585 | expect(result.current.status).toBe('idle'); 586 | expect(result.current.data).toBeUndefined(); 587 | expect(finalizeFn).toHaveBeenCalledTimes(0); 588 | expect(testSubscriptionFn).toHaveBeenCalledTimes(0); 589 | }); 590 | 591 | it('should load once enabled = true', async () => { 592 | const { result, rerender, waitForNextUpdate } = renderHook( 593 | ({ enabled }: UseSubscriptionOptions) => 594 | useSubscription(testSubscriptionKey, testSubscriptionFn, { 595 | enabled, 596 | }), 597 | { wrapper: Wrapper, initialProps: { enabled: false } } 598 | ); 599 | expect(result.current.data).toBeUndefined(); 600 | 601 | rerender({ enabled: true }); 602 | 603 | await waitForNextUpdate(); 604 | expect(result.current.status).toBe('loading'); 605 | expect(result.current.data).toBeUndefined(); 606 | 607 | await waitForNextUpdate(); 608 | expect(result.current.status).toBe('success'); 609 | expect(result.current.data).toBe(0); 610 | }); 611 | }); 612 | 613 | describe('retry', () => { 614 | it('should retry failed subscription 2 times', async () => { 615 | const testErrorSubscriptionFn = vi.fn(() => { 616 | throw new Error('Test Error'); 617 | }); 618 | 619 | const { result, waitFor } = renderHook( 620 | () => 621 | useSubscription(testSubscriptionKey, testErrorSubscriptionFn, { 622 | retry: 2, 623 | }), 624 | { wrapper: Wrapper } 625 | ); 626 | expect(result.current.status).toBe('loading'); 627 | expect(result.current.data).toBeUndefined(); 628 | 629 | await waitFor( 630 | () => { 631 | expect(result.current.status).toBe('error'); 632 | }, 633 | { timeout: 10000 } 634 | ); 635 | expect(result.current.error).toEqual(new Error('Test Error')); 636 | expect(result.current.failureCount).toBe(3); 637 | expect(result.current.data).toBeUndefined(); 638 | expect(testErrorSubscriptionFn).toHaveBeenCalledTimes(3); 639 | }); 640 | 641 | it('should not retry subscription if successfully subscribed but error emitted', async () => { 642 | const testErrorSubscriptionFn = vi.fn(() => 643 | interval(testInterval).pipe( 644 | map((n) => { 645 | if (n === 2) { 646 | throw new Error('Test Error'); 647 | } 648 | return n; 649 | }) 650 | ) 651 | ); 652 | 653 | const { result, waitFor } = renderHook( 654 | () => 655 | useSubscription(testSubscriptionKey, testErrorSubscriptionFn, { 656 | retry: 2, 657 | }), 658 | { wrapper: Wrapper } 659 | ); 660 | expect(result.current.status).toBe('loading'); 661 | expect(result.current.data).toBeUndefined(); 662 | expect(testErrorSubscriptionFn).toHaveBeenCalledTimes(1); 663 | testErrorSubscriptionFn.mockClear(); 664 | 665 | await waitFor( 666 | () => { 667 | expect(result.current.status).toBe('error'); 668 | }, 669 | { timeout: 10000 } 670 | ); 671 | expect(result.current.error).toEqual(new Error('Test Error')); 672 | // the queryFn runs 3x but the subscriptionFn is not called 673 | expect(result.current.failureCount).toBe(3); 674 | expect(result.current.data).toBe(1); 675 | expect(testErrorSubscriptionFn).not.toHaveBeenCalled(); 676 | }); 677 | }); 678 | 679 | describe('retryOnMount', () => { 680 | const testErrorSubscriptionFn = () => { 681 | throw new Error('Test Error'); 682 | }; 683 | 684 | const testStreamErrorSubscriptionFn = () => 685 | interval(testInterval).pipe( 686 | map((n) => { 687 | if (n === 2) { 688 | throw new Error('Test Error'); 689 | } 690 | return n; 691 | }) 692 | ); 693 | 694 | it.each` 695 | subscriptionFn | hasPreviousData | description 696 | ${testErrorSubscriptionFn} | ${false} | ${`subscribe error`} 697 | ${testStreamErrorSubscriptionFn} | ${true} | ${`stream error`} 698 | `( 699 | 'should retry previously failed subscription ($description)', 700 | async ({ subscriptionFn, hasPreviousData }) => { 701 | const fn = vi.fn(() => subscriptionFn()); 702 | const firstHookRender = renderHook( 703 | () => 704 | useSubscription(testSubscriptionKey, fn, { 705 | retryOnMount: true, 706 | }), 707 | { wrapper: Wrapper } 708 | ); 709 | await firstHookRender.waitFor(() => { 710 | expect(firstHookRender.result.current.status).toBe('error'); 711 | }); 712 | expect(fn).toHaveBeenCalledTimes(1); 713 | fn.mockClear(); 714 | 715 | await new Promise((resolve) => setTimeout(resolve, 1000)); 716 | 717 | const { result, unmount, waitFor } = renderHook( 718 | () => 719 | useSubscription(testSubscriptionKey, fn, { 720 | retryOnMount: true, 721 | }), 722 | { wrapper: Wrapper } 723 | ); 724 | expect(result.current.status).toBe('loading'); 725 | if (hasPreviousData) { 726 | expect(result.current.data).toBe(1); 727 | } else { 728 | expect(result.current.data).toBeUndefined(); 729 | } 730 | 731 | await waitFor(() => { 732 | expect(result.current.status).toBe('error'); 733 | }); 734 | expect(fn).toHaveBeenCalledTimes(1); 735 | 736 | firstHookRender.unmount(); 737 | unmount(); 738 | } 739 | ); 740 | 741 | it.each` 742 | subscriptionFn | description 743 | ${testErrorSubscriptionFn} | ${`failed to subscribe`} 744 | ${testStreamErrorSubscriptionFn} | ${`stream error`} 745 | `( 746 | 'should not retry previously failed subscription ($description)', 747 | async ({ subscriptionFn }) => { 748 | const fn = vi.fn(() => subscriptionFn()); 749 | const firstHookRender = renderHook( 750 | () => 751 | useSubscription(testSubscriptionKey, fn, { 752 | retryOnMount: false, 753 | }), 754 | { wrapper: Wrapper } 755 | ); 756 | await firstHookRender.waitFor(() => { 757 | expect(firstHookRender.result.current.status).toBe('error'); 758 | }); 759 | expect(fn).toHaveBeenCalledTimes(1); 760 | fn.mockClear(); 761 | 762 | const { result, unmount } = renderHook( 763 | () => 764 | useSubscription(testSubscriptionKey, fn, { 765 | retryOnMount: false, 766 | }), 767 | { wrapper: Wrapper } 768 | ); 769 | expect(result.current.status).toBe('error'); 770 | 771 | expect(fn).not.toHaveBeenCalled(); 772 | expect(result.current.status).toBe('error'); 773 | 774 | firstHookRender.unmount(); 775 | unmount(); 776 | } 777 | ); 778 | }); 779 | 780 | describe('select', () => { 781 | it('should apply select function', async () => { 782 | const { result, waitForNextUpdate } = renderHook( 783 | () => 784 | useSubscription(testSubscriptionKey, testSubscriptionFn, { 785 | select: (data) => 10 * (data + 1), 786 | }), 787 | { wrapper: Wrapper } 788 | ); 789 | expect(result.current.data).toBeUndefined(); 790 | 791 | await waitForNextUpdate(); 792 | expect(result.current.data).toBe(10); 793 | 794 | await waitForNextUpdate(); 795 | expect(result.current.data).toBe(20); 796 | }); 797 | }); 798 | 799 | describe('placeholderData', () => { 800 | it('should support placeholder data', async () => { 801 | const { result, waitForNextUpdate } = renderHook( 802 | () => 803 | useSubscription(testSubscriptionKey, testSubscriptionFn, { 804 | placeholderData: 100, 805 | }), 806 | { wrapper: Wrapper } 807 | ); 808 | expect(result.current.status).toBe('success'); 809 | expect(result.current.data).toBe(100); 810 | 811 | await waitForNextUpdate(); 812 | expect(result.current.status).toBe('success'); 813 | expect(result.current.data).toBe(0); 814 | 815 | await waitForNextUpdate(); 816 | expect(result.current.status).toBe('success'); 817 | expect(result.current.data).toBe(1); 818 | }); 819 | }); 820 | }); 821 | 822 | describe('returns', () => { 823 | test('refetch', async () => { 824 | const { result, waitFor, unmount } = renderHook( 825 | () => useSubscription(testSubscriptionKey, testSubscriptionFn), 826 | { wrapper: Wrapper } 827 | ); 828 | await waitFor(() => { 829 | expect(result.current.status).toBe('success'); 830 | expect(result.current.data).toBe(1); 831 | }); 832 | expect(testSubscriptionFn).toHaveBeenCalledTimes(1); 833 | 834 | const refetchPromise = result.current.refetch(); 835 | expect(result.current.status).toBe('success'); 836 | expect(result.current.data).toBe(1); 837 | 838 | await refetchPromise; 839 | expect(result.current.status).toBe('success'); 840 | expect(result.current.data).toBe(0); 841 | 842 | expect(testSubscriptionFn).toHaveBeenCalledTimes(2); 843 | unmount(); 844 | }); 845 | }); 846 | 847 | describe('queryClient', () => { 848 | test('invalidateQueries', async () => { 849 | const { result, waitFor, unmount } = renderHook( 850 | () => useSubscription(testSubscriptionKey, testSubscriptionFn), 851 | { wrapper: Wrapper } 852 | ); 853 | await waitFor(() => { 854 | expect(result.current.status).toBe('success'); 855 | expect(result.current.data).toBe(1); 856 | }); 857 | expect(testSubscriptionFn).toHaveBeenCalledTimes(1); 858 | 859 | queryClient.invalidateQueries(testSubscriptionKey); 860 | expect(result.current.status).toBe('success'); 861 | expect(result.current.data).toBe(1); 862 | 863 | await waitFor(() => { 864 | expect(result.current.status).toBe('success'); 865 | expect(result.current.data).toBe(0); 866 | }); 867 | expect(testSubscriptionFn).toHaveBeenCalledTimes(2); 868 | unmount(); 869 | }); 870 | }); 871 | }); 872 | -------------------------------------------------------------------------------- /src/helpers/__tests__/event-source.spec.ts: -------------------------------------------------------------------------------- 1 | import { firstValueFrom, of } from 'rxjs'; 2 | import { catchError, take, toArray } from 'rxjs/operators'; 3 | import type { SpyInstance } from 'vitest'; 4 | 5 | import { eventSource$ } from '../event-source'; 6 | 7 | import { server } from '../../__api-mocks__/server'; 8 | 9 | describe('EventSource helpers', () => { 10 | let closeSseSpy: SpyInstance; 11 | 12 | beforeEach(() => { 13 | closeSseSpy = vi.spyOn(global.EventSource.prototype, 'close'); 14 | }); 15 | 16 | afterEach(() => { 17 | closeSseSpy?.mockRestore(); 18 | }); 19 | 20 | const requestListener = vi.fn(); 21 | 22 | beforeEach(() => { 23 | server.events.on('request:start', requestListener); 24 | }); 25 | 26 | afterEach(() => { 27 | server.events.removeListener('request:start', requestListener); 28 | requestListener.mockClear(); 29 | }); 30 | 31 | function createAbsoluteUrl(relativePath: string): string { 32 | return new URL(relativePath, window.location.href).toString(); 33 | } 34 | 35 | describe('eventSource$', () => { 36 | it('should emit values from event source', async () => { 37 | const sse$ = eventSource$(createAbsoluteUrl('/sse')); 38 | expect(await firstValueFrom(sse$.pipe(take(5), toArray()))) 39 | .toMatchInlineSnapshot(` 40 | [ 41 | "hello", 42 | "world", 43 | 123, 44 | "green", 45 | { 46 | "test": "red", 47 | }, 48 | ] 49 | `); 50 | }); 51 | 52 | it('should not open the EventSource until subscribed', async () => { 53 | const sse$ = eventSource$(createAbsoluteUrl('/sse')); 54 | expect(requestListener).not.toHaveBeenCalled(); 55 | 56 | const promise = firstValueFrom(sse$.pipe(take(5), toArray())); 57 | expect(requestListener).toHaveBeenCalledTimes(1); 58 | 59 | await promise; 60 | }); 61 | 62 | it('should close the EventSource once unsubscribed', async () => { 63 | const sse$ = eventSource$(createAbsoluteUrl('/sse')); 64 | await firstValueFrom(sse$.pipe(take(5), toArray())); 65 | 66 | expect(closeSseSpy).toHaveBeenCalledTimes(1); 67 | }); 68 | 69 | it('should emit values from event source until error', async () => { 70 | const sse$ = eventSource$(createAbsoluteUrl('/sse/error')); 71 | expect( 72 | await firstValueFrom( 73 | sse$.pipe( 74 | take(5), 75 | catchError((error) => of(new Error(error.message))), 76 | toArray() 77 | ) 78 | ) 79 | ).toMatchInlineSnapshot(` 80 | [ 81 | "hello", 82 | "world", 83 | 123, 84 | [Error: Test Error], 85 | ] 86 | `); 87 | }); 88 | 89 | it('should fail to subscribe', async () => { 90 | expect.assertions(2); 91 | const sse$ = eventSource$(createAbsoluteUrl('/sse/network-error')); 92 | try { 93 | await firstValueFrom(sse$.pipe(take(5), toArray())); 94 | } catch (error) { 95 | expect(error).toBeInstanceOf(Error); 96 | expect(error?.message).toMatchInlineSnapshot(`"Event Source Error"`); 97 | } 98 | }); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /src/helpers/event-source.ts: -------------------------------------------------------------------------------- 1 | import { fromEvent, merge, Observable, of } from 'rxjs'; 2 | import { map, finalize, takeUntil, switchMap } from 'rxjs/operators'; 3 | 4 | export interface EventSourceOptions { 5 | /** 6 | * Event types to subscribe to. 7 | * @default ['message'] 8 | */ 9 | eventTypes: Array; 10 | /** 11 | * Function to parse messages. 12 | * @default (_, message) => JSON.parse(message) 13 | */ 14 | parseFn: (eventType: string, message: string) => TData; 15 | } 16 | 17 | /** 18 | * Takes EventSource and creates an observable from it. 19 | * 20 | * @deprecated Use `fromEventSource` from `rx-event-source` package instead. 21 | * 22 | * @example 23 | * ```ts 24 | * const sse = new EventSource(url, configuration); 25 | * return fromEventSource(sse).pipe( 26 | * finalize(() => { 27 | * // Make sure the EventSource is closed once not needed. 28 | * sse.close(); 29 | * }), 30 | * ); 31 | * ``` 32 | */ 33 | export function fromEventSource( 34 | sse: EventSource, 35 | options?: EventSourceOptions 36 | ): Observable { 37 | const defaultOptions: EventSourceOptions = { 38 | eventTypes: ['message'], 39 | parseFn: (_, message) => JSON.parse(message), 40 | }; 41 | const { eventTypes, parseFn } = { ...defaultOptions, ...(options || {}) }; 42 | const events$ = eventTypes.map((eventType) => 43 | fromEvent<{ data: string }>(sse, eventType).pipe( 44 | map((event: MessageEvent) => parseFn(eventType, event.data)) 45 | ) 46 | ); 47 | 48 | const error$ = fromEvent<{ data?: string }>(sse, 'error').pipe( 49 | map((message) => JSON.parse(message?.data || 'null')), 50 | map((data) => { 51 | throw new Error(data?.errorMessage || 'Event Source Error'); 52 | }) 53 | ); 54 | 55 | const complete$ = fromEvent(sse, 'complete'); 56 | return merge(...events$, error$).pipe(takeUntil(complete$)); 57 | } 58 | 59 | /** 60 | * Creates event source from url (and config) and returns an observable with 61 | * parsed event source data. 62 | * Opens the event source once subscribed. 63 | * Closes the event source, once unsubscribed. 64 | * 65 | * @deprecated Use `eventSource$` from `rx-event-source` package instead. 66 | * 67 | * @example 68 | * ```ts 69 | * const sse$ = eventSource$('https://example.com/sse', { 70 | * withCredentials: true, 71 | * }); 72 | * sse$.subscribe((data) => { 73 | * console.log(data); 74 | * }); 75 | * ``` 76 | */ 77 | export function eventSource$( 78 | url: string, 79 | configuration?: EventSourceInit, 80 | options?: EventSourceOptions 81 | ): Observable { 82 | return of({ url, configuration }).pipe( 83 | switchMap(({ url, configuration }) => { 84 | const sse = new EventSource(url, configuration); 85 | return fromEventSource(sse, options).pipe( 86 | finalize(() => { 87 | sse.close(); 88 | }) 89 | ); 90 | }) 91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { useSubscription } from './use-subscription'; 2 | export type { UseSubscriptionOptions } from './use-subscription'; 3 | export { useInfiniteSubscription } from './use-infinite-subscription'; 4 | export type { UseInfiniteSubscriptionOptions } from './use-infinite-subscription'; 5 | 6 | export { eventSource$, fromEventSource } from './helpers/event-source'; 7 | export type { EventSourceOptions } from './helpers/event-source'; 8 | -------------------------------------------------------------------------------- /src/subscription-storage.ts: -------------------------------------------------------------------------------- 1 | import type { QueryClient } from 'react-query'; 2 | import { Subscription } from 'rxjs'; 3 | 4 | const clientCacheSubscriptionsKey = ['__activeSubscriptions__']; 5 | const defaultKey = Symbol('__default__'); 6 | 7 | type SubscriptionStorageItem = Map; 8 | type SubscriptionStorage = Map; 9 | 10 | /** 11 | * Stores subscription by its key and `pageParam` in the clientCache. 12 | */ 13 | export function storeSubscription( 14 | queryClient: QueryClient, 15 | hashedSubscriptionKey: string, 16 | subscription: Subscription, 17 | pageParam?: string 18 | ) { 19 | const activeSubscriptions: SubscriptionStorage = 20 | queryClient.getQueryData(clientCacheSubscriptionsKey) || new Map(); 21 | 22 | const previousSubscription = activeSubscriptions.get(hashedSubscriptionKey); 23 | 24 | let newSubscriptionValue: SubscriptionStorageItem; 25 | if (previousSubscription) { 26 | previousSubscription.set(pageParam ?? defaultKey, subscription); 27 | newSubscriptionValue = previousSubscription; 28 | } else { 29 | newSubscriptionValue = new Map([[pageParam ?? defaultKey, subscription]]); 30 | } 31 | 32 | activeSubscriptions.set(hashedSubscriptionKey, newSubscriptionValue); 33 | 34 | queryClient.setQueryData(clientCacheSubscriptionsKey, activeSubscriptions); 35 | } 36 | 37 | /** 38 | * Removes stored subscription by its key and `pageParam` from the clientCache. 39 | */ 40 | export function cleanupSubscription( 41 | queryClient: QueryClient, 42 | hashedSubscriptionKey: string, 43 | pageParam?: string 44 | ) { 45 | const activeSubscriptions: SubscriptionStorage = 46 | queryClient.getQueryData(clientCacheSubscriptionsKey) || new Map(); 47 | 48 | const subscription = activeSubscriptions.get(hashedSubscriptionKey); 49 | 50 | if (!subscription) return; 51 | 52 | if (pageParam === undefined) { 53 | subscription.forEach((subscription) => { 54 | subscription.unsubscribe(); 55 | }); 56 | activeSubscriptions.delete(hashedSubscriptionKey); 57 | } else { 58 | subscription.get(pageParam)?.unsubscribe(); 59 | subscription.delete(pageParam); 60 | activeSubscriptions.set(hashedSubscriptionKey, subscription); 61 | } 62 | 63 | queryClient.setQueryData(clientCacheSubscriptionsKey, activeSubscriptions); 64 | } 65 | -------------------------------------------------------------------------------- /src/use-infinite-subscription.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useInfiniteQuery, useQueryClient, hashQueryKey } from 'react-query'; 3 | import type { 4 | QueryKey, 5 | UseInfiniteQueryResult, 6 | PlaceholderDataFunction, 7 | QueryFunctionContext, 8 | InfiniteData, 9 | GetPreviousPageParamFunction, 10 | GetNextPageParamFunction, 11 | } from 'react-query'; 12 | import type { 13 | RetryDelayValue, 14 | RetryValue, 15 | } from 'react-query/types/core/retryer'; 16 | import { Observable } from 'rxjs'; 17 | 18 | import { useObservableQueryFn } from './use-observable-query-fn'; 19 | import { cleanupSubscription } from './subscription-storage'; 20 | 21 | export interface UseInfiniteSubscriptionOptions< 22 | TSubscriptionFnData = unknown, 23 | TError = Error, 24 | TData = TSubscriptionFnData, 25 | TSubscriptionKey extends QueryKey = QueryKey 26 | > { 27 | /** 28 | * This function can be set to automatically get the previous cursor for infinite queries. 29 | * The result will also be used to determine the value of `hasPreviousPage`. 30 | */ 31 | getPreviousPageParam?: GetPreviousPageParamFunction; 32 | /** 33 | * This function can be set to automatically get the next cursor for infinite queries. 34 | * The result will also be used to determine the value of `hasNextPage`. 35 | */ 36 | getNextPageParam?: GetNextPageParamFunction; 37 | /** 38 | * Set this to `false` to disable automatic resubscribing when the subscription mounts or changes subscription keys. 39 | * To refetch the subscription, use the `refetch` method returned from the `useSubscription` instance. 40 | * Defaults to `true`. 41 | */ 42 | enabled?: boolean; 43 | /** 44 | * If `false`, failed subscriptions will not retry by default. 45 | * If `true`, failed subscriptions will retry infinitely. 46 | * If set to an integer number, e.g. 3, failed subscriptions will retry until the failed subscription count meets that number. 47 | * If set to a function `(failureCount: number, error: TError) => boolean` failed subscriptions will retry until the function returns false. 48 | */ 49 | retry?: RetryValue; 50 | /** 51 | * If number, applies delay before next attempt in milliseconds. 52 | * If function, it receives a `retryAttempt` integer and the actual Error and returns the delay to apply before the next attempt in milliseconds. 53 | * @see https://react-query.tanstack.com/reference/useQuery 54 | */ 55 | retryDelay?: RetryDelayValue; 56 | /** 57 | * If set to `false`, the subscription will not be retried on mount if it contains an error. 58 | * Defaults to `true`. 59 | */ 60 | retryOnMount?: boolean; 61 | /** 62 | * This callback will fire if the subscription encounters an error and will be passed the error. 63 | */ 64 | onError?: (error: TError) => void; 65 | /** 66 | * This option can be used to transform or select a part of the data returned by the query function. 67 | */ 68 | select?: (data: InfiniteData) => InfiniteData; 69 | /** 70 | * If set, this value will be used as the placeholder data for this particular query observer while the subscription is still in the `loading` data and no initialData has been provided. 71 | */ 72 | placeholderData?: 73 | | InfiniteData 74 | | PlaceholderDataFunction>; 75 | /** 76 | * This function will fire any time the subscription successfully fetches new data or the cache is updated via setQueryData. 77 | */ 78 | onData?: (data: InfiniteData) => void; 79 | } 80 | 81 | export type UseInfiniteSubscriptionResult< 82 | TData = unknown, 83 | TError = unknown 84 | > = UseInfiniteQueryResult; 85 | 86 | // eslint-disable-next-line @typescript-eslint/ban-types 87 | function inOperator( 88 | k: K, 89 | o: T 90 | ): o is T & Record { 91 | return k in o; 92 | } 93 | 94 | function isInfiniteData(value: unknown): value is InfiniteData { 95 | return ( 96 | value && 97 | typeof value === 'object' && 98 | inOperator('pages', value) && 99 | Array.isArray(value.pages) && 100 | inOperator('pageParams', value) && 101 | Array.isArray(value.pageParams) 102 | ); 103 | } 104 | 105 | /** 106 | * React hook based on React Query for managing, caching and syncing observables 107 | * in React with infinite pagination. 108 | * 109 | * @example 110 | * ```tsx 111 | * function ExampleInfiniteSubscription() { 112 | * const { 113 | * data, 114 | * isError, 115 | * error, 116 | * isFetchingNextPage, 117 | * hasNextPage, 118 | * fetchNextPage, 119 | * } = useInfiniteSubscription( 120 | * 'test-key', 121 | * () => stream$, 122 | * { 123 | * getNextPageParam: (lastPage, allPages) => lastPage.nextCursor, 124 | * // other options 125 | * } 126 | * ); 127 | * 128 | * if (isError) { 129 | * return ( 130 | *
131 | * {error?.message || 'Unknown error'} 132 | *
133 | * ); 134 | * } 135 | * return <> 136 | * {data.pages.map((page) => ( 137 | *
{JSON.stringify(page)}
138 | * ))} 139 | * {isFetchingNextPage && <>Loading...} 140 | * {hasNextPage && ( 141 | * 142 | * )} 143 | * ; 144 | * } 145 | * ``` 146 | */ 147 | export function useInfiniteSubscription< 148 | TSubscriptionFnData = unknown, 149 | TError = Error, 150 | TData = TSubscriptionFnData, 151 | TSubscriptionKey extends QueryKey = QueryKey 152 | >( 153 | subscriptionKey: TSubscriptionKey, 154 | subscriptionFn: ( 155 | context: QueryFunctionContext 156 | ) => Observable, 157 | options: UseInfiniteSubscriptionOptions< 158 | TSubscriptionFnData, 159 | TError, 160 | TData, 161 | TSubscriptionKey 162 | > = {} 163 | ): UseInfiniteSubscriptionResult { 164 | const hashedSubscriptionKey = hashQueryKey(subscriptionKey); 165 | 166 | const { queryFn, clearErrors } = useObservableQueryFn( 167 | subscriptionFn, 168 | (data, previousData, pageParam): InfiniteData => { 169 | if (!isInfiniteData(previousData)) { 170 | return { 171 | pages: [data], 172 | pageParams: [pageParam], 173 | }; 174 | } 175 | const pageIndex = previousData.pageParams.findIndex( 176 | (cursor) => pageParam === cursor 177 | ); 178 | return { 179 | pages: [ 180 | ...(previousData.pages.slice(0, pageIndex) as TSubscriptionFnData[]), 181 | data, 182 | ...(previousData.pages.slice(pageIndex + 1) as TSubscriptionFnData[]), 183 | ], 184 | pageParams: previousData.pageParams, 185 | }; 186 | } 187 | ); 188 | 189 | const queryClient = useQueryClient(); 190 | 191 | const queryResult = useInfiniteQuery< 192 | TSubscriptionFnData, 193 | TError, 194 | TData, 195 | TSubscriptionKey 196 | >(subscriptionKey, queryFn, { 197 | retry: false, 198 | ...options, 199 | staleTime: Infinity, 200 | refetchInterval: undefined, 201 | refetchOnMount: true, 202 | refetchOnWindowFocus: false, 203 | refetchOnReconnect: false, 204 | onError: (error: TError) => { 205 | clearErrors(); 206 | options.onError && options.onError(error); 207 | }, 208 | }); 209 | 210 | useEffect(() => { 211 | if (queryResult.isSuccess) { 212 | options.onData?.(queryResult.data); 213 | } 214 | // eslint-disable-next-line react-hooks/exhaustive-deps 215 | }, [queryResult.data]); 216 | 217 | useEffect(() => { 218 | return function cleanup() { 219 | // Fixes unsubscribe 220 | // We cannot assume that this fn runs for this component. 221 | // It might be a different observer associated to the same query key. 222 | // https://github.com/tannerlinsley/react-query/blob/16b7d290c70639b627d9ada32951d211eac3adc3/src/core/query.ts#L376 223 | 224 | const activeObserversCount = queryClient 225 | .getQueryCache() 226 | .find(subscriptionKey) 227 | ?.getObserversCount(); 228 | 229 | if (activeObserversCount === 0) { 230 | cleanupSubscription(queryClient, hashedSubscriptionKey); 231 | } 232 | }; 233 | // This is safe as `hashedSubscriptionKey` is derived from `subscriptionKey`. 234 | // eslint-disable-next-line react-hooks/exhaustive-deps 235 | }, [queryClient, hashedSubscriptionKey]); 236 | 237 | return queryResult; 238 | } 239 | -------------------------------------------------------------------------------- /src/use-observable-query-fn.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | import { useQueryClient, hashQueryKey } from 'react-query'; 3 | import type { 4 | QueryFunction, 5 | QueryKey, 6 | QueryFunctionContext, 7 | } from 'react-query'; 8 | import { Observable, of, firstValueFrom } from 'rxjs'; 9 | import { catchError, finalize, share, tap, skip } from 'rxjs/operators'; 10 | 11 | import { storeSubscription, cleanupSubscription } from './subscription-storage'; 12 | 13 | export interface UseObservableQueryFnResult< 14 | TSubscriptionFnData = unknown, 15 | TSubscriptionKey extends QueryKey = QueryKey 16 | > { 17 | queryFn: QueryFunction; 18 | clearErrors: () => void; 19 | } 20 | 21 | export function useObservableQueryFn< 22 | TSubscriptionFnData = unknown, 23 | TCacheData = TSubscriptionFnData, 24 | TSubscriptionKey extends QueryKey = QueryKey 25 | >( 26 | subscriptionFn: ( 27 | context: QueryFunctionContext 28 | ) => Observable, 29 | dataUpdater: ( 30 | data: TSubscriptionFnData, 31 | previousData: unknown, 32 | pageParam: unknown | undefined 33 | ) => TCacheData 34 | ): UseObservableQueryFnResult { 35 | const queryClient = useQueryClient(); 36 | 37 | // We cannot assume that this fn runs for this component. 38 | // It might be a different observer associated to the same query key. 39 | // https://github.com/tannerlinsley/react-query/blob/16b7d290c70639b627d9ada32951d211eac3adc3/src/core/query.ts#L376 40 | // @todo: move from the component scope to queryCache 41 | const failRefetchWith = useRef(false); 42 | 43 | const queryFn: QueryFunction = ( 44 | context 45 | ) => { 46 | const { queryKey: subscriptionKey, pageParam, signal } = context; 47 | const hashedSubscriptionKey = hashQueryKey(subscriptionKey); 48 | 49 | if (failRefetchWith.current) { 50 | throw failRefetchWith.current; 51 | } 52 | 53 | type Result = Promise & { cancel?: () => void }; 54 | 55 | const stream$ = subscriptionFn(context).pipe(share()); 56 | const result: Result = firstValueFrom(stream$); 57 | 58 | // Fixes scenario when component unmounts before first emit. 59 | // If we do not invalidate the query, the hook will never re-subscribe, 60 | // as data are otherwise marked as fresh. 61 | function cancel() { 62 | queryClient.invalidateQueries(subscriptionKey, undefined, { 63 | cancelRefetch: false, 64 | }); 65 | } 66 | // `signal` is available on context from ReactQuery 3.30.0 67 | // If `AbortController` is not available in the current runtime environment 68 | // ReactQuery sets `signal` to `undefined`. In that case we fallback to 69 | // old API, attaching `cancel` fn on promise. 70 | // @see https://tanstack.com/query/v4/docs/guides/query-cancellation 71 | if (signal) { 72 | signal.addEventListener('abort', cancel); 73 | } else { 74 | /* istanbul ignore next */ 75 | result.cancel = cancel; 76 | } 77 | 78 | // @todo: Skip subscription for SSR 79 | cleanupSubscription( 80 | queryClient, 81 | hashedSubscriptionKey, 82 | pageParam ?? undefined 83 | ); 84 | 85 | const subscription = stream$ 86 | .pipe( 87 | skip(1), 88 | tap((data) => { 89 | queryClient.setQueryData(subscriptionKey, (previousData) => 90 | dataUpdater(data, previousData, pageParam) 91 | ); 92 | }), 93 | catchError((error) => { 94 | failRefetchWith.current = error; 95 | queryClient.setQueryData(subscriptionKey, (data) => data, { 96 | // To make the retryOnMount work 97 | // @see: https://github.com/tannerlinsley/react-query/blob/9e414e8b4f3118b571cf83121881804c0b58a814/src/core/queryObserver.ts#L727 98 | updatedAt: 0, 99 | }); 100 | return of(undefined); 101 | }), 102 | finalize(() => { 103 | queryClient.invalidateQueries(subscriptionKey, undefined, { 104 | cancelRefetch: false, 105 | }); 106 | }) 107 | ) 108 | .subscribe(); 109 | 110 | // remember the current subscription 111 | // see `cleanup` fn for more info 112 | storeSubscription( 113 | queryClient, 114 | hashedSubscriptionKey, 115 | subscription, 116 | pageParam ?? undefined 117 | ); 118 | 119 | return result; 120 | }; 121 | 122 | return { 123 | queryFn, 124 | // @todo incorporate into `queryFn`? 125 | clearErrors: () => { 126 | // Once the error has been thrown, and a query result created (with error) 127 | // cleanup the `failRefetchWith`. 128 | failRefetchWith.current = false; 129 | }, 130 | }; 131 | } 132 | -------------------------------------------------------------------------------- /src/use-subscription.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useQuery, useQueryClient, hashQueryKey } from 'react-query'; 3 | import type { 4 | QueryKey, 5 | UseQueryResult, 6 | QueryFunctionContext, 7 | PlaceholderDataFunction, 8 | } from 'react-query'; 9 | import type { 10 | RetryDelayValue, 11 | RetryValue, 12 | } from 'react-query/types/core/retryer'; 13 | import { Observable } from 'rxjs'; 14 | 15 | import { useObservableQueryFn } from './use-observable-query-fn'; 16 | import { cleanupSubscription } from './subscription-storage'; 17 | 18 | export interface UseSubscriptionOptions< 19 | TSubscriptionFnData = unknown, 20 | TError = Error, 21 | TData = TSubscriptionFnData, 22 | TSubscriptionKey extends QueryKey = QueryKey 23 | > { 24 | /** 25 | * Set this to `false` to disable automatic resubscribing when the subscription mounts or changes subscription keys. 26 | * To refetch the subscription, use the `refetch` method returned from the `useSubscription` instance. 27 | * Defaults to `true`. 28 | */ 29 | enabled?: boolean; 30 | /** 31 | * If `false`, failed subscriptions will not retry by default. 32 | * If `true`, failed subscriptions will retry infinitely. 33 | * If set to an integer number, e.g. 3, failed subscriptions will retry until the failed subscription count meets that number. 34 | * If set to a function `(failureCount: number, error: TError) => boolean` failed subscriptions will retry until the function returns false. 35 | */ 36 | retry?: RetryValue; 37 | /** 38 | * If number, applies delay before next attempt in milliseconds. 39 | * If function, it receives a `retryAttempt` integer and the actual Error and returns the delay to apply before the next attempt in milliseconds. 40 | * @see https://react-query.tanstack.com/reference/useQuery 41 | */ 42 | retryDelay?: RetryDelayValue; 43 | /** 44 | * If set to `false`, the subscription will not be retried on mount if it contains an error. 45 | * Defaults to `true`. 46 | */ 47 | retryOnMount?: boolean; 48 | /** 49 | * This callback will fire if the subscription encounters an error and will be passed the error. 50 | */ 51 | onError?: (error: TError) => void; 52 | /** 53 | * This option can be used to transform or select a part of the data returned by the query function. 54 | */ 55 | select?: (data: TSubscriptionFnData) => TData; 56 | /** 57 | * If set, this value will be used as the placeholder data for this particular query observer while the subscription is still in the `loading` data and no initialData has been provided. 58 | */ 59 | placeholderData?: 60 | | TSubscriptionFnData 61 | | PlaceholderDataFunction; 62 | /** 63 | * This function will fire any time the subscription successfully fetches new data or the cache is updated via setQueryData. 64 | */ 65 | onData?: (data: TData) => void; 66 | } 67 | 68 | export type UseSubscriptionResult< 69 | TData = unknown, 70 | TError = unknown 71 | > = UseQueryResult; 72 | 73 | /** 74 | * React hook based on React Query for managing, caching and syncing observables in React. 75 | * 76 | * @example 77 | * ```ts 78 | * function ExampleSubscription() { 79 | * const { data, isError, error, isLoading } = useSubscription( 80 | * 'test-key', 81 | * () => stream$, 82 | * { 83 | * // options 84 | * } 85 | * ); 86 | * 87 | * if (isLoading) { 88 | * return <>Loading...; 89 | * } 90 | * if (isError) { 91 | * return ( 92 | *
93 | * {error?.message || 'Unknown error'} 94 | *
95 | * ); 96 | * } 97 | * return <>Data: {JSON.stringify(data)}; 98 | * } 99 | * ``` 100 | */ 101 | export function useSubscription< 102 | TSubscriptionFnData = unknown, 103 | TError = Error, 104 | TData = TSubscriptionFnData, 105 | TSubscriptionKey extends QueryKey = QueryKey 106 | >( 107 | subscriptionKey: TSubscriptionKey, 108 | subscriptionFn: ( 109 | context: QueryFunctionContext 110 | ) => Observable, 111 | options: UseSubscriptionOptions< 112 | TSubscriptionFnData, 113 | TError, 114 | TData, 115 | TSubscriptionKey 116 | > = {} 117 | ): UseSubscriptionResult { 118 | const hashedSubscriptionKey = hashQueryKey(subscriptionKey); 119 | 120 | const { queryFn, clearErrors } = useObservableQueryFn( 121 | subscriptionFn, 122 | (data) => data 123 | ); 124 | 125 | const queryClient = useQueryClient(); 126 | 127 | const queryResult = useQuery< 128 | TSubscriptionFnData, 129 | TError, 130 | TData, 131 | TSubscriptionKey 132 | >(subscriptionKey, queryFn, { 133 | retry: false, 134 | ...options, 135 | staleTime: Infinity, 136 | refetchInterval: undefined, 137 | refetchOnMount: true, 138 | refetchOnWindowFocus: false, 139 | refetchOnReconnect: false, 140 | onError: (error: TError) => { 141 | clearErrors(); 142 | options.onError && options.onError(error); 143 | }, 144 | }); 145 | 146 | useEffect(() => { 147 | if (queryResult.isSuccess) { 148 | options.onData?.(queryResult.data); 149 | } 150 | // eslint-disable-next-line react-hooks/exhaustive-deps 151 | }, [queryResult.data]); 152 | 153 | useEffect(() => { 154 | return function cleanup() { 155 | // Fixes unsubscribe 156 | // We cannot assume that this fn runs for this component. 157 | // It might be a different observer associated to the same query key. 158 | // https://github.com/tannerlinsley/react-query/blob/16b7d290c70639b627d9ada32951d211eac3adc3/src/core/query.ts#L376 159 | 160 | const activeObserversCount = queryClient 161 | .getQueryCache() 162 | .find(subscriptionKey) 163 | ?.getObserversCount(); 164 | 165 | if (activeObserversCount === 0) { 166 | cleanupSubscription(queryClient, hashedSubscriptionKey); 167 | } 168 | }; 169 | // This is safe as `hashedSubscriptionKey` is derived from `subscriptionKey`. 170 | // eslint-disable-next-line react-hooks/exhaustive-deps 171 | }, [queryClient, hashedSubscriptionKey]); 172 | 173 | return queryResult; 174 | } 175 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "target": "es5", 5 | "jsx": "react", 6 | "esModuleInterop": true, 7 | "downlevelIteration": true, 8 | "types": ["vitest/globals"], 9 | "skipLibCheck": true 10 | }, 11 | "include": ["src/**/*"] 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.types.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "declarationDir": "./types", 6 | "emitDeclarationOnly": true, 7 | "noEmit": false, 8 | "paths": { 9 | "react-query-subscription": ["./src/index.ts"] 10 | } 11 | }, 12 | "files": ["./src/index.ts"], 13 | "exclude": ["./src/**/*"] 14 | } 15 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryPoints": ["./src/index.ts"], 3 | "exclude": [ 4 | "**/*.(spec|test).ts", 5 | "__mocks__/**", 6 | "__tests__/**", 7 | "__api-mocks__/**" 8 | ], 9 | "excludeExternals": true, 10 | "excludeInternal": true, 11 | "excludeProtected": true, 12 | "excludePrivate": true, 13 | "includeVersion": true, 14 | "out": "./gh-pages" 15 | } 16 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | environment: 'jsdom', 7 | setupFiles: ['./vitest/setup-env.ts'], 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /vitest/setup-env.ts: -------------------------------------------------------------------------------- 1 | import { setLogger } from 'react-query'; 2 | import EventSource from 'eventsource'; 3 | 4 | import { server } from '../src/__api-mocks__/server'; 5 | 6 | setLogger({ 7 | log: console.log, 8 | warn: console.warn, 9 | // eslint-disable-next-line @typescript-eslint/no-empty-function 10 | error: () => {}, 11 | }); 12 | 13 | /** 14 | * MSW 15 | * @see https://mswjs.io/docs/getting-started/install 16 | */ 17 | beforeAll(() => server.listen()); 18 | afterEach(() => server.resetHandlers()); 19 | afterAll(() => server.close()); 20 | 21 | /** 22 | * Mock EventSource 23 | */ 24 | Object.defineProperty(global, 'EventSource', { 25 | value: EventSource, 26 | }); 27 | --------------------------------------------------------------------------------