├── .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 | [](https://github.com/kaciakmaciak/react-query-subscription/releases)
10 | [![All Contributors][all-contributors-badge]](#contributors-)
11 | [](https://gitpod.io/#https://github.com/kaciakmaciak/react-query-subscription)
12 | [](https://codecov.io/gh/kaciakmaciak/react-query-subscription)
13 | 
14 | 
15 | 
16 | [](https://www.npmjs.com/package/react-query-subscription)
17 | [](LICENSE)
18 |
19 | [API Reference](https://kaciakmaciak.github.io/react-query-subscription/)
20 |
21 | [](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 |
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 |
54 |
55 | {(isLoadingMessages || isLoadingUsers || isLoadingUser) && (
56 |
Loading...
57 | )}
58 | {(isMessagesError || isUsersError || isUserError) && (
59 |
60 | An error occurred while loading messages. Please, try again later.
61 |
62 | )}
63 |
64 | {user &&
65 | users &&
66 | messages?.pages?.map((page) => (
67 |
68 | {page.data.map((message) => {
69 | const messageUser = users.find(
70 | (user) => user.id === message.userId
71 | );
72 | return (
73 |
74 |
79 |
83 |
84 | {messageUser?.username ?? 'Unknown'}
85 |
86 |
{message.message}
87 |
88 |
89 | {format(message.sentAt, 'PPPp')}
90 |
91 |
92 | );
93 | })}
94 |
95 | ))}
96 | {hasNextPage && (
97 |
101 | )}
102 |
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 |
24 | {Boolean(chatMatch) && (
25 |
26 |
27 |
28 | )}
29 |
30 |
31 |
32 | {Boolean(aboutMatch) && }
33 |
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 |
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 | * Load more
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 |
--------------------------------------------------------------------------------