├── .clocignore ├── .gitignore ├── .prettierrc ├── .tool-versions ├── .vscode └── settings.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Makefile ├── README.md ├── babel.config.json ├── babel.config.lib.json ├── legacy ├── _lib │ └── infinite.ts ├── index.ts ├── useAll │ ├── index.ts │ ├── package.json │ └── test.ts ├── useGet │ ├── index.ts │ ├── package.json │ └── test.ts ├── useGetMany │ ├── index.ts │ ├── package.json │ └── test.ts ├── useInfiniteQuery │ ├── index.ts │ ├── package.json │ └── test.ts ├── useInfiniteScroll │ ├── index.ts │ └── package.json ├── useOnAll │ ├── index.ts │ ├── package.json │ └── test.ts ├── useOnGet │ ├── index.ts │ ├── package.json │ └── test.ts ├── useOnGetMany │ ├── index.ts │ ├── package.json │ └── test.ts ├── useOnQuery │ ├── index.ts │ ├── package.json │ └── test.ts └── useQuery │ ├── index.ts │ ├── package.json │ └── test.ts ├── package-lock.json ├── package.json ├── scripts ├── patchPreactopod.ts └── patchReactopod.ts ├── src ├── adapter │ ├── index.ts │ ├── preact.ts │ └── react.ts ├── index.ts ├── types.ts ├── useLazyRead │ └── index.ts └── useRead │ └── index.ts ├── test ├── _lib │ └── utils.ts ├── karmaTests.ts ├── setupJestAfterEnv.ts ├── setupJestLocal.ts └── setupJestSystem.ts ├── tsconfig.json └── tsconfig.lib.json /.clocignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | secrets 3 | lib -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | /secrets 4 | .envrc 5 | docs 6 | firestore-debug.log 7 | firebase-debug.log -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 20.10.0 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "workbench.colorCustomizations": { 4 | "activityBar.activeBackground": "#4e8ed4", 5 | "activityBar.activeBorder": "#9a255d", 6 | "activityBar.background": "#4e8ed4", 7 | "activityBar.foreground": "#15202b", 8 | "activityBar.inactiveForeground": "#15202b99", 9 | "activityBarBadge.background": "#9a255d", 10 | "activityBarBadge.foreground": "#e7e7e7", 11 | "statusBar.background": "#2f74c0", 12 | "statusBar.foreground": "#e7e7e7", 13 | "statusBarItem.hoverBackground": "#4e8ed4", 14 | "titleBar.activeBackground": "#2f74c0", 15 | "titleBar.activeForeground": "#e7e7e7", 16 | "titleBar.inactiveBackground": "#2f74c099", 17 | "titleBar.inactiveForeground": "#e7e7e799", 18 | "commandCenter.border": "#e7e7e799", 19 | "sash.hoverBorder": "#4e8ed4", 20 | "statusBarItem.remoteBackground": "#2f74c0", 21 | "statusBarItem.remoteForeground": "#e7e7e7" 22 | }, 23 | "peacock.remoteColor": "#2f74c0" 24 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | This project adheres to [Semantic Versioning]. 5 | This change log follows the format documented in [Keep a CHANGELOG]. 6 | 7 | [semantic versioning]: http://semver.org/ 8 | [keep a changelog]: http://keepachangelog.com/ 9 | 10 | ## v6.0.1 - 2024-01-29 11 | 12 | ### Fixed 13 | 14 | - Fixed the adapter imports in the ESM version. 15 | 16 | - Added the `useLazyRead` export to `package.json`. 17 | 18 | ## v6.0.0 - 2024-01-28 19 | 20 | Completely revamped Typesaurus with a new API and new features. [Follow this guide to learn how it works](https://typesaurus.com/get-started/). 21 | 22 | ## v4.0.1 - 2020-12-08 23 | 24 | ### Fixed 25 | 26 | - Fixed `useInfiniteQuery` behavior when the collection or query are changing. Before, the previous result would not be cleared. 27 | 28 | ## v4.0.0 - 2020-09-06 29 | 30 | ### Changed 31 | 32 | - **BREAKING**: The hooks now return a tuple where the result is the first item, and the second is an object with loading and error states. 33 | 34 | ### Added 35 | 36 | - `useGet` and `useOnGet` now accept references (i.e. `useGet(user.ref)`). 37 | 38 | ## v3.0.0 - 2020-04-17 39 | 40 | ### Changed 41 | 42 | - **BREAKING**: When using with ESM-enabled bundler, you should transpile `node_modules`. TypeScript preserves many modern languages features when it compiles to ESM code. So if you have to support older browsers, use Babel to process the dependencies code. 43 | 44 | ### Added 45 | 46 | - Added ESM version of the code that enables tree-shaking. 47 | 48 | ## v2.0.0 - 2020-04-10 49 | 50 | ### Changed 51 | 52 | - **BREAKING**: Now, when query or collection is changed, hook state resets to `undefined` while previously it would stay as is until the new data is fetched. 53 | 54 | ## v1.0.0 - 2020-02-20 55 | 56 | ### Changed 57 | 58 | - **BREAKING**: Rename `reactopod` and `preactopod` to `@typesaurus/react` and `@typesaurus/preact`. 59 | 60 | ### Added 61 | 62 | - Add functions: 63 | - `useGetMany` 64 | - `useOnGetMany` 65 | 66 | ## v0.4.2 - 2020-01-27 67 | 68 | ### Fixed 69 | 70 | - Fixed the Preact package. 71 | 72 | ## v0.4.0 - 2020-01-27 73 | 74 | ### Added 75 | 76 | - Added functions: 77 | - `useInfiniteQuery` 78 | - `useInfiniteScroll` 79 | - `useAll` 80 | - `useOnAll` 81 | 82 | ## v0.3.0 - 2020-01-15 83 | 84 | ### Changed 85 | 86 | - **BREAKING**: `reactopod` now only supports React. 87 | 88 | - Get rid of webpack warning during compilation. 89 | 90 | ### Added 91 | 92 | - Publish separate `preactopod` package for Preact. 93 | 94 | ## v0.2.0 - 2020-01-14 95 | 96 | ### Changed 97 | 98 | - Make Reactopod work both with React and Preact. 99 | 100 | ## v0.1.0 - 2020-01-13 101 | 102 | ### Added 103 | 104 | - Added functions: 105 | - `useGet` 106 | - `useOnGet` 107 | - `useQuery` 108 | - `useOnQuery` 109 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Code style 4 | 5 | Please use [Prettier](https://prettier.io/) to format the code. 6 | 7 | ## Tests 8 | 9 | ### Unit tests 10 | 11 | Before running unit tests for the first time, you need to download the Firestore emulator by running the command: 12 | 13 | ```bash 14 | make test-setup 15 | ``` 16 | 17 | To run the tests: 18 | 19 | ```bash 20 | # Run tests once 21 | make test 22 | 23 | # Run tests in the watch mode 24 | make test-watch 25 | ``` 26 | 27 | ### System tests 28 | 29 | Typesaurus React system tests connect to a real database, so to run them, you need to prepare a Firebase project and point the suite to the project. See [How to set up tests?](#how-to-set-up-system-tests) for more details. 30 | 31 | #### How to run system tests? 32 | 33 | To run the tests: 34 | 35 | ```bash 36 | # Run system tests: 37 | make test-system 38 | 39 | # Run system tests in the watch mode: 40 | make test-system-watch 41 | ``` 42 | 43 | #### How to set up system tests? 44 | 45 | 1. First of all, [create a Firebase project](https://console.firebase.google.com/) and [enable Firestore](https://console.firebase.google.com/project/_/storage). 46 | 47 | 2. Set the project ID and web API key to `FIREBASE_PROJECT_ID` and `FIREBASE_API_KEY` respectively. 48 | 49 | 3. You also might want to create a user with a password (set email to `FIREBASE_USERNAME` and password to `FIREBASE_PASSWORD`) and set your rules to allow writes and reads only to this user: 50 | 51 | ``` 52 | rules_version = '2'; 53 | service cloud.firestore { 54 | match /databases/{database}/documents { 55 | match /{document=**} { 56 | allow read, write: if request.auth.uid == "xxx"; 57 | } 58 | } 59 | } 60 | ``` 61 | 62 | However, this step is optional and should not be a concern unless you make your web API key public. 63 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := build 2 | .PHONY: build 3 | 4 | types: 5 | npx tsc --build 6 | 7 | types-watch: 8 | npx tsc --build --watch 9 | 10 | test-types: install-attw build 11 | @cd lib/reactopod && attw --pack 12 | @cd lib/preactopod && attw --pack 13 | 14 | build: 15 | @rm -rf lib 16 | @npx tsc --project tsconfig.lib.json 17 | @env BABEL_ENV=esm npx babel src --config-file ./babel.config.lib.json --source-root src --out-dir lib/reactopod --extensions .mjs,.ts --out-file-extension .mjs --quiet 18 | @env BABEL_ENV=cjs npx babel src --config-file ./babel.config.lib.json --source-root src --out-dir lib/reactopod --extensions .mjs,.ts --out-file-extension .js --quiet 19 | @make build-mts 20 | @cp package.json lib/reactopod 21 | @cp *.md lib/reactopod 22 | @cp -r lib/reactopod lib/preactopod 23 | @npx tsx scripts/patchReactopod.ts 24 | @npx tsx scripts/patchPreactopod.ts 25 | 26 | build-mts: 27 | @find lib/reactopod -name '*.d.ts' | while read file; do \ 28 | new_file=$${file%.d.ts}.d.mts; \ 29 | cp $$file $$new_file; \ 30 | done 31 | 32 | publish: build 33 | @cd lib/reactopod && npm publish --access public 34 | @cd lib/preactopod && npm publish --access public 35 | 36 | publish-next: build 37 | @cd lib/reactopod && npm publish --access public --tag next 38 | @cd lib/preactopod && npm publish --access public --tag next 39 | 40 | install-attw: 41 | @if ! command -v attw >/dev/null 2>&1; then \ 42 | npm i -g @arethetypeswrong/cli; \ 43 | fi -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 🎉️ NEW: [Typesaurus X is out](https://blog.typesaurus.com/typesaurus-x-is-out/)! 2 | 3 | # 🦕 Typesaurus React 4 | 5 | React Hooks for [Typesaurus](https://github.com/kossnocorp/typesaurus), type-safe Firestore ODM. 6 | 7 | → [Read docs](https://typesaurus.com/integrations/react/) 8 | 9 | ## Installation 10 | 11 | The library is available as an [npm package](https://www.npmjs.com/package/@typesaurus/react). 12 | To install Typesaurus, run: 13 | 14 | ```sh 15 | npm install --save @typesaurus/react typesaurus firebase firebase-admin 16 | ``` 17 | 18 | _Note that Typesaurus React has Typesaurus listed as a peer dependency, which also requires the `firebase` package to work in the web environment and `firebase-admin` to work in Node.js. These packages aren't listed as dependencies, so they won't install automatically with the Typesaurus package. Also, you'll need `react` installed._ 19 | 20 | ## Changelog 21 | 22 | See [the changelog](./CHANGELOG.md). 23 | 24 | ## License 25 | 26 | [MIT © Sasha Koss](https://kossnocorp.mit-license.org/) 27 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", { "targets": { "node": "current" } }], 4 | "@babel/preset-typescript" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /babel.config.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-typescript"], 3 | 4 | "env": { 5 | "cjs": { 6 | "presets": [ 7 | [ 8 | "@babel/preset-env", 9 | { 10 | "targets": { "node": "current" }, 11 | "modules": "commonjs", 12 | "loose": true 13 | } 14 | ] 15 | ], 16 | 17 | "plugins": [ 18 | [ 19 | "@babel/plugin-transform-modules-commonjs", 20 | { "strict": true, "noInterop": true } 21 | ], 22 | ["babel-plugin-add-import-extension", { "extension": "js" }] 23 | ] 24 | }, 25 | 26 | "esm": { 27 | "presets": [ 28 | [ 29 | "@babel/preset-env", 30 | { "targets": { "node": "current" }, "modules": false } 31 | ] 32 | ], 33 | 34 | "plugins": [["babel-plugin-add-import-extension", { "extension": "mjs" }]] 35 | } 36 | }, 37 | 38 | "ignore": [ 39 | "src/**/*.d.ts", 40 | "src/**/tests.ts", 41 | "src/tests/**/*", 42 | "src/**/tysts.ts", 43 | "src/tysts/**/*" 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /legacy/_lib/infinite.ts: -------------------------------------------------------------------------------- 1 | import { FirestoreOrderByDirection } from 'typesaurus/adaptor' 2 | 3 | export type InfiniteLoadMoreFunction = () => void 4 | 5 | export type InfiniteLoadMoreState = InfiniteLoadMoreFunction | undefined | null 6 | 7 | export type InfiniteQueryOptions = { 8 | field: Field 9 | limit: number 10 | method?: FirestoreOrderByDirection 11 | } 12 | 13 | export type InfiniteCursorsState = { 14 | [cursorId: string]: 'loading' | 'loaded' 15 | } 16 | 17 | export type InfiniteQueryHookResultMeta = { 18 | loadMore: InfiniteLoadMoreState 19 | loadedAll: boolean 20 | } 21 | -------------------------------------------------------------------------------- /legacy/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useAll } from './useAll' 2 | export { default as useGet } from './useGet' 3 | export { default as useGetMany } from './useGetMany' 4 | export { default as useInfiniteQuery } from './useInfiniteQuery' 5 | export { default as useInfiniteScroll } from './useInfiniteScroll' 6 | export { default as useOnAll } from './useOnAll' 7 | export { default as useOnGet } from './useOnGet' 8 | export { default as useOnGetMany } from './useOnGetMany' 9 | export { default as useOnQuery } from './useOnQuery' 10 | export { default as useQuery } from './useQuery' 11 | -------------------------------------------------------------------------------- /legacy/useAll/index.ts: -------------------------------------------------------------------------------- 1 | import { all, AllOptionns } from 'typesaurus/all' 2 | import { Collection } from 'typesaurus/collection' 3 | import { AnyDoc } from 'typesaurus/doc' 4 | import { RuntimeEnvironment, ServerTimestampsStrategy } from 'typesaurus/types' 5 | import { useEffect, useState } from '../adaptor' 6 | import { TypesaurusHookResult } from '../types' 7 | 8 | /** 9 | * Returns all documents in a collection. 10 | * 11 | * ```ts 12 | * import { useAll } from '@typesaurus/react' 13 | * 14 | * type User = { name: string } 15 | * const users = collection('users') 16 | * 17 | * function Users() { 18 | * const [allUsers] = useAll(users) 19 | * } 20 | * all(users).then(allUsers => { 21 | * console.log(allUsers.length) 22 | * //=> 420 23 | * console.log(allUsers[0].ref.id) 24 | * //=> '00sHm46UWKObv2W7XK9e' 25 | * console.log(allUsers[0].data) 26 | * //=> { name: 'Sasha' } 27 | * }) 28 | * ``` 29 | * 30 | * @param collection - The collection to get all documents from 31 | * @returns A promise to all documents 32 | */ 33 | export default function useAll< 34 | Model, 35 | Environment extends RuntimeEnvironment | undefined, 36 | ServerTimestamps extends ServerTimestampsStrategy 37 | >( 38 | collection: Collection, 39 | options?: AllOptionns 40 | ): TypesaurusHookResult< 41 | AnyDoc[] | undefined 42 | > { 43 | const [result, setResult] = useState< 44 | AnyDoc[] | undefined 45 | >(undefined) 46 | const [error, setError] = useState(undefined) 47 | const loading = result === undefined && !error 48 | 49 | const deps = [JSON.stringify(collection)] 50 | useEffect(() => { 51 | if (result) setResult(undefined) 52 | all(collection, options).then(setResult).catch(setError) 53 | }, deps) 54 | 55 | return [result, { loading, error }] 56 | } 57 | -------------------------------------------------------------------------------- /legacy/useAll/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "module": "../esm/useAll/index.js", 3 | "sideEffects": false 4 | } 5 | -------------------------------------------------------------------------------- /legacy/useAll/test.ts: -------------------------------------------------------------------------------- 1 | import * as testing from '@firebase/testing' 2 | import { renderHook } from '@testing-library/react-hooks' 3 | import assert from 'assert' 4 | import { Collection, collection, ref, Ref, set } from 'typesaurus' 5 | import { setApp } from 'typesaurus/testing' 6 | import useAll from '.' 7 | import { lockDB } from '../../test/_lib/utils' 8 | 9 | describe('useAll', () => { 10 | type Book = { title: string } 11 | type Order = { book: Ref; quantity: number; date?: Date } 12 | const books = collection('books') 13 | const orders = collection('orders') 14 | 15 | const date = new Date(1987, 1, 11) 16 | 17 | beforeEach(async () => { 18 | setApp(testing.initializeAdminApp({ projectId: 'project-id' })) 19 | 20 | await Promise.all([ 21 | set(books, 'sapiens', { title: 'Sapiens' }), 22 | set(books, '22laws', { title: 'The 22 Immutable Laws of Marketing' }), 23 | set(books, 'momtest', { title: 'The Mom Test' }), 24 | set(orders, 'order1', { 25 | book: ref(books, 'sapiens'), 26 | quantity: 1, 27 | date 28 | }), 29 | set(orders, 'order2', { 30 | book: ref(books, '22laws'), 31 | quantity: 1, 32 | date 33 | }) 34 | ]) 35 | }) 36 | 37 | it('returns all documents', async () => { 38 | const { result, waitForNextUpdate } = renderHook(() => useAll(books)) 39 | assert(result.current[0] === undefined) 40 | await waitForNextUpdate() 41 | const [docs] = result.current 42 | assert.deepEqual(docs!.map(({ data: { title } }) => title).sort(), [ 43 | 'Sapiens', 44 | 'The 22 Immutable Laws of Marketing', 45 | 'The Mom Test' 46 | ]) 47 | }) 48 | 49 | it('cleans the data and refetch when the collection is changing', async () => { 50 | const initialProps: { collection: Collection } = { collection: books } 51 | const { result, waitForNextUpdate, rerender } = renderHook( 52 | ({ collection }) => useAll(collection), 53 | { initialProps } 54 | ) 55 | assert(result.current[0] === undefined) 56 | await waitForNextUpdate() 57 | assert(result.current[0]) 58 | rerender({ collection: orders }) 59 | assert(result.current[0] === undefined) 60 | await waitForNextUpdate() 61 | assert(result.current[0]![0].data.date.getTime() === date.getTime()) 62 | }) 63 | 64 | it('returns an empty array if the collection is empty', async () => { 65 | const { result, waitForNextUpdate } = renderHook(() => 66 | useAll(collection('nope')) 67 | ) 68 | await waitForNextUpdate() 69 | assert.deepEqual(result.current[0], []) 70 | }) 71 | 72 | it('returns loading state', async () => { 73 | const { result, waitForNextUpdate } = renderHook(() => useAll(books)) 74 | assert(result.current[1].loading) 75 | await waitForNextUpdate() 76 | assert(!result.current[1].loading) 77 | }) 78 | 79 | it('returns error state', async () => { 80 | await lockDB() 81 | const { result, waitForNextUpdate } = renderHook(() => useAll(books)) 82 | assert(result.current[1].loading) 83 | assert(!result.current[1].error) 84 | await waitForNextUpdate() 85 | assert(!result.current[1].loading) 86 | 87 | assert(result.current[1].error) 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /legacy/useGet/index.ts: -------------------------------------------------------------------------------- 1 | import type { Ref } from 'typesaurus/ref' 2 | import type { Collection } from 'typesaurus/collection' 3 | import type { AnyDoc, Doc } from 'typesaurus/doc' 4 | import { get, GetOptions } from 'typesaurus/get' 5 | import { useEffect, useState } from '../adaptor' 6 | import { TypesaurusHookResult } from '../types' 7 | import { RuntimeEnvironment, ServerTimestampsStrategy } from 'typesaurus/types' 8 | 9 | /** 10 | * @param ref - The reference to the document 11 | */ 12 | export default function useGet< 13 | Model, 14 | Environment extends RuntimeEnvironment | undefined, 15 | ServerTimestamps extends ServerTimestampsStrategy 16 | >( 17 | ref: Ref | undefined, 18 | options?: GetOptions 19 | ): TypesaurusHookResult< 20 | typeof ref extends undefined 21 | ? undefined 22 | : AnyDoc | null | undefined 23 | > 24 | 25 | /** 26 | * @param collection - The collection to get document from 27 | * @param id - The document id 28 | */ 29 | export default function useGet< 30 | Model, 31 | Environment extends RuntimeEnvironment | undefined, 32 | ServerTimestamps extends ServerTimestampsStrategy 33 | >( 34 | collection: Collection, 35 | id: string | undefined, 36 | options?: GetOptions 37 | ): TypesaurusHookResult< 38 | typeof id extends undefined 39 | ? undefined 40 | : AnyDoc | null | undefined 41 | > 42 | 43 | export default function useGet< 44 | Model, 45 | Environment extends RuntimeEnvironment | undefined, 46 | ServerTimestamps extends ServerTimestampsStrategy 47 | >( 48 | collectionOrRef: Collection | Ref | undefined, 49 | maybeIdOrOptions?: string | GetOptions, 50 | maybeOptions?: GetOptions 51 | ) { 52 | let collection: Collection | undefined 53 | let id: string | undefined 54 | let options: GetOptions | undefined 55 | 56 | if (collectionOrRef && collectionOrRef.__type__ === 'collection') { 57 | collection = collectionOrRef as Collection 58 | id = maybeIdOrOptions as string | undefined 59 | options = maybeOptions 60 | } else { 61 | const ref = collectionOrRef as Ref | undefined 62 | collection = ref?.collection 63 | id = ref?.id 64 | options = maybeIdOrOptions as 65 | | GetOptions 66 | | undefined 67 | } 68 | 69 | const [result, setResult] = useState | null | undefined>(undefined) 70 | const [error, setError] = useState(undefined) 71 | const loading = result === undefined && !error 72 | 73 | const deps = [JSON.stringify(collection), id] 74 | useEffect(() => { 75 | if (result) setResult(undefined) 76 | if (collection && id) 77 | get(collection, id, options).then(setResult).catch(setError) 78 | }, deps) 79 | 80 | return [result, { loading, error }] 81 | } 82 | -------------------------------------------------------------------------------- /legacy/useGet/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "module": "../esm/useGet/index.js", 3 | "sideEffects": false 4 | } 5 | -------------------------------------------------------------------------------- /legacy/useGet/test.ts: -------------------------------------------------------------------------------- 1 | import * as testing from '@firebase/testing' 2 | import { renderHook } from '@testing-library/react-hooks' 3 | import assert from 'assert' 4 | import { add, Collection, collection, Ref, set } from 'typesaurus' 5 | import { setApp } from 'typesaurus/testing' 6 | import useGet from '.' 7 | import { lockDB } from '../../test/_lib/utils' 8 | 9 | describe('useGet', () => { 10 | type User = { name: string } 11 | const users = collection('users') 12 | const altUsers = collection('altUsers') 13 | 14 | beforeEach(async () => { 15 | setApp(testing.initializeAdminApp({ projectId: 'project-id' })) 16 | }) 17 | 18 | it('returns the requested document', async () => { 19 | const user = await add(users, { name: 'Sasha' }) 20 | const { result, waitForNextUpdate } = renderHook(() => 21 | useGet(users, user.id) 22 | ) 23 | assert(result.current[0] === undefined) 24 | await waitForNextUpdate() 25 | const [doc] = result.current 26 | assert(doc!.data.name === 'Sasha') 27 | }) 28 | 29 | it('accepts refs', async () => { 30 | const user = await add(users, { name: 'Sasha' }) 31 | const { result, waitForNextUpdate } = renderHook(() => useGet(user)) 32 | await waitForNextUpdate() 33 | assert(result.current[0]!.data.name === 'Sasha') 34 | }) 35 | 36 | it('returns null if the document is not found', async () => { 37 | const { result, waitForNextUpdate } = renderHook(() => 38 | useGet(users, 'nope') 39 | ) 40 | assert(result.current[0] === undefined) 41 | await waitForNextUpdate() 42 | assert(result.current[0] === null) 43 | }) 44 | 45 | it('cleans the data and refetch when the collection is changing', async () => { 46 | const user = await add(users, { name: 'Sasha' }) 47 | await set(altUsers, user.id, { name: 'Alexander' }) 48 | const initialProps: { collection: Collection } = { collection: users } 49 | const { result, waitForNextUpdate, rerender } = renderHook( 50 | ({ collection }) => useGet(collection, user.id), 51 | { initialProps } 52 | ) 53 | assert(result.current[0] === undefined) 54 | await waitForNextUpdate() 55 | assert(result.current[0]) 56 | rerender({ collection: altUsers }) 57 | assert(result.current[0] === undefined) 58 | await waitForNextUpdate() 59 | assert(result.current[0]!.data.name === 'Alexander') 60 | }) 61 | 62 | it('cleans the data and refetch when the id is changing', async () => { 63 | const [user, anotherUser] = await Promise.all([ 64 | add(users, { name: 'Sasha' }), 65 | add(users, { name: 'Tati' }) 66 | ]) 67 | const initialProps: { id: string } = { id: user.id } 68 | const { result, waitForNextUpdate, rerender } = renderHook( 69 | ({ id }) => useGet(users, id), 70 | { initialProps } 71 | ) 72 | assert(result.current[0] === undefined) 73 | await waitForNextUpdate() 74 | assert(result.current[0]) 75 | rerender({ id: anotherUser.id }) 76 | assert(result.current[0] === undefined) 77 | await waitForNextUpdate() 78 | assert(result.current[0]!.data.name === 'Tati') 79 | }) 80 | 81 | it('when the id is undefined the hook waits for it', async () => { 82 | const user = await add(users, { name: 'Sasha' }) 83 | const initialProps: { id: string | undefined } = { id: undefined } 84 | const { result, rerender, waitForNextUpdate } = renderHook( 85 | ({ id }) => useGet(users, id), 86 | { initialProps } 87 | ) 88 | assert(result.current[0] === undefined) 89 | assert(result.current[1].loading) 90 | rerender({ id: user.id }) 91 | await waitForNextUpdate() 92 | const [doc] = result.current 93 | assert(doc!.data.name === 'Sasha') 94 | }) 95 | 96 | it('when the ref is undefined the hook waits for it', async () => { 97 | const user = await add(users, { name: 'Sasha' }) 98 | const initialProps: { ref: Ref | undefined } = { ref: undefined } 99 | const { result, rerender, waitForNextUpdate } = renderHook( 100 | ({ ref }) => useGet(ref), 101 | { initialProps } 102 | ) 103 | assert(result.current[0] === undefined) 104 | assert(result.current[1].loading) 105 | rerender({ ref: user }) 106 | await waitForNextUpdate() 107 | const [doc] = result.current 108 | assert(doc!.data.name === 'Sasha') 109 | }) 110 | 111 | it('returns loading state', async () => { 112 | const user = await add(users, { name: 'Sasha' }) 113 | const { result, waitForNextUpdate } = renderHook(() => 114 | useGet(users, user.id) 115 | ) 116 | assert(result.current[1].loading) 117 | await waitForNextUpdate() 118 | assert(!result.current[1].loading) 119 | }) 120 | 121 | it('returns error state', async () => { 122 | const user = await add(users, { name: 'Sasha' }) 123 | await lockDB() 124 | const { result, waitForNextUpdate } = renderHook(() => 125 | useGet(users, user.id) 126 | ) 127 | assert(result.current[1].loading) 128 | assert(!result.current[1].error) 129 | await waitForNextUpdate() 130 | assert(!result.current[1].loading) 131 | assert(result.current[1].error) 132 | }) 133 | }) 134 | -------------------------------------------------------------------------------- /legacy/useGetMany/index.ts: -------------------------------------------------------------------------------- 1 | import { Collection } from 'typesaurus/collection' 2 | import { AnyDoc } from 'typesaurus/doc' 3 | import { getMany, GetManyOptions } from 'typesaurus/getMany' 4 | import { RuntimeEnvironment, ServerTimestampsStrategy } from 'typesaurus/types' 5 | import { useEffect, useState } from '../adaptor' 6 | import { TypesaurusHookResult } from '../types' 7 | 8 | export default function useGetMany< 9 | Model, 10 | Environment extends RuntimeEnvironment | undefined, 11 | ServerTimestamps extends ServerTimestampsStrategy 12 | >( 13 | collection: Collection, 14 | ids: readonly string[] | undefined, 15 | options?: GetManyOptions 16 | ): TypesaurusHookResult< 17 | typeof ids extends undefined 18 | ? undefined 19 | : AnyDoc[] | undefined 20 | > { 21 | const [result, setResult] = useState< 22 | AnyDoc[] | undefined 23 | >(undefined) 24 | const [error, setError] = useState(undefined) 25 | const loading = result === undefined && !error 26 | 27 | const deps = [JSON.stringify(collection), JSON.stringify(ids)] 28 | useEffect(() => { 29 | if (result) setResult(undefined) 30 | if (ids) getMany(collection, ids, options).then(setResult).catch(setError) 31 | }, deps) 32 | 33 | return [result, { loading, error }] 34 | } 35 | -------------------------------------------------------------------------------- /legacy/useGetMany/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "module": "../esm/useGetMany/index.js", 3 | "sideEffects": false 4 | } 5 | -------------------------------------------------------------------------------- /legacy/useGetMany/test.ts: -------------------------------------------------------------------------------- 1 | import * as testing from '@firebase/testing' 2 | import { renderHook } from '@testing-library/react-hooks' 3 | import assert from 'assert' 4 | import { Collection, collection, set } from 'typesaurus' 5 | import { setApp } from 'typesaurus/testing' 6 | import useGetMany from '.' 7 | import { lockDB } from '../../test/_lib/utils' 8 | 9 | describe('useGetMany', () => { 10 | type Fruit = { color: string } 11 | const fruits = collection('fruits') 12 | const altFruits = collection('altFruits') 13 | 14 | beforeAll(async () => { 15 | setApp(testing.initializeAdminApp({ projectId: 'project-id' })) 16 | 17 | await Promise.all([ 18 | set(fruits, 'apple', { color: 'green' }), 19 | set(fruits, 'banana', { color: 'yellow' }), 20 | set(fruits, 'orange', { color: 'orange' }) 21 | ]) 22 | }) 23 | 24 | beforeEach(() => { 25 | setApp(testing.initializeAdminApp({ projectId: 'project-id' })) 26 | }) 27 | 28 | it('allows to get multiple docs by id', async () => { 29 | const { result, waitForNextUpdate } = renderHook(() => 30 | useGetMany(fruits, ['banana', 'apple', 'banana', 'orange']) 31 | ) 32 | assert(result.current[0] === undefined) 33 | await waitForNextUpdate() 34 | const [docs] = result.current 35 | expect(docs!.length).toBe(4) 36 | expect(docs![0].ref.id).toBe('banana') 37 | expect(docs![1].ref.id).toBe('apple') 38 | expect(docs![2].ref.id).toBe('banana') 39 | expect(docs![3].ref.id).toBe('orange') 40 | }) 41 | 42 | it('cleans the data and refetch when the collection is changing', async () => { 43 | await Promise.all([ 44 | set(altFruits, 'apple', { color: 'зеленый' }), 45 | set(altFruits, 'banana', { color: 'желтый' }), 46 | set(altFruits, 'orange', { color: 'оранжевый' }) 47 | ]) 48 | const initialProps: { collection: Collection } = { collection: fruits } 49 | const { result, waitForNextUpdate, rerender } = renderHook( 50 | ({ collection }) => 51 | useGetMany(collection, ['banana', 'apple', 'banana', 'orange']), 52 | { initialProps } 53 | ) 54 | assert(result.current[0] === undefined) 55 | await waitForNextUpdate() 56 | assert(result.current[0]) 57 | rerender({ collection: altFruits }) 58 | assert(result.current[0] === undefined) 59 | await waitForNextUpdate() 60 | assert(result.current[0]![0].data.color === 'желтый') 61 | }) 62 | 63 | it('cleans the data and refetch when the ids is changing', async () => { 64 | const initialProps: { ids: string[] } = { 65 | ids: ['banana', 'apple', 'banana', 'orange'] 66 | } 67 | const { result, waitForNextUpdate, rerender } = renderHook( 68 | ({ ids }) => useGetMany(fruits, ids), 69 | { initialProps } 70 | ) 71 | assert(result.current[0] === undefined) 72 | await waitForNextUpdate() 73 | assert(result.current[0]) 74 | rerender({ ids: ['orange'] }) 75 | assert(result.current[0] === undefined) 76 | await waitForNextUpdate() 77 | assert(result.current[0]!.length === 1) 78 | assert(result.current[0]![0].data.color === 'orange') 79 | }) 80 | 81 | it('returns loading state', async () => { 82 | const { result, waitForNextUpdate } = renderHook(() => 83 | useGetMany(fruits, ['banana']) 84 | ) 85 | assert(result.current[1].loading) 86 | await waitForNextUpdate() 87 | assert(!result.current[1].loading) 88 | }) 89 | 90 | it('returns error state', async () => { 91 | await lockDB() 92 | const { result, waitForNextUpdate } = renderHook(() => 93 | useGetMany(fruits, ['banana']) 94 | ) 95 | assert(result.current[1].loading) 96 | assert(!result.current[1].error) 97 | await waitForNextUpdate() 98 | assert(!result.current[1].loading) 99 | assert(result.current[1].error) 100 | }) 101 | 102 | it('assign an error when an id is missing', async () => { 103 | const { result, waitForNextUpdate } = renderHook(() => 104 | useGetMany(fruits, ['nonexistant']) 105 | ) 106 | assert(!result.current[1].error) 107 | await waitForNextUpdate() 108 | assert(result.current[1].error instanceof Error) 109 | assert( 110 | (result.current[1].error as Error).message === 111 | 'Missing document with id nonexistant' 112 | ) 113 | }) 114 | 115 | it('allows to specify custom logic when a document is not found', async () => { 116 | const { result, waitForNextUpdate } = renderHook(() => 117 | useGetMany(fruits, ['nonexistant'], id => ({ 118 | color: `${id} is missing but I filled it in` 119 | })) 120 | ) 121 | await waitForNextUpdate() 122 | const [docs] = result.current 123 | expect(docs!.length).toBe(1) 124 | expect(docs![0].data.color).toBe( 125 | 'nonexistant is missing but I filled it in' 126 | ) 127 | }) 128 | 129 | it('allows to ignore missing documents', async () => { 130 | const { result, waitForNextUpdate } = renderHook(() => 131 | useGetMany(fruits, ['apple', 'nonexistant', 'banana'], 'ignore') 132 | ) 133 | await waitForNextUpdate() 134 | const [docs] = result.current 135 | expect(docs!.length).toBe(2) 136 | }) 137 | }) 138 | -------------------------------------------------------------------------------- /legacy/useInfiniteQuery/index.ts: -------------------------------------------------------------------------------- 1 | import { Collection } from 'typesaurus/collection' 2 | import { startAfter } from 'typesaurus/cursor' 3 | import { AnyDoc } from 'typesaurus/doc' 4 | import { CollectionGroup } from 'typesaurus/group' 5 | import { limit } from 'typesaurus/limit' 6 | import { order } from 'typesaurus/order' 7 | import { query, QueryOptions } from 'typesaurus/query' 8 | import { 9 | Query, 10 | RuntimeEnvironment, 11 | ServerTimestampsStrategy 12 | } from 'typesaurus/types' 13 | import { useEffect, useRef, useState } from '../adaptor' 14 | import { TypesaurusHookResult } from '../types' 15 | import { 16 | InfiniteCursorsState, 17 | InfiniteQueryHookResultMeta, 18 | InfiniteQueryOptions 19 | } from '../_lib/infinite' 20 | 21 | export default function useInfiniteQuery< 22 | Model, 23 | FieldName extends keyof Model, 24 | Environment extends RuntimeEnvironment | undefined, 25 | ServerTimestamps extends ServerTimestampsStrategy 26 | >( 27 | collection: Collection | CollectionGroup, 28 | queries: Query[] | undefined, 29 | options: InfiniteQueryOptions & 30 | QueryOptions 31 | ): TypesaurusHookResult< 32 | AnyDoc[] | undefined, 33 | InfiniteQueryHookResultMeta 34 | > { 35 | // The props (collection and queries) might change, or in case of queries, 36 | // be undefined. When they change, all the result state must be reset. 37 | // When queries is undefined, any requests must be delayed. 38 | // 39 | // Since there are plenty of moving parts, these props sync via references 40 | // that update in an effect. 41 | 42 | // The props references. 43 | const collectionRef = useRef< 44 | Collection | CollectionGroup | undefined 45 | >(undefined) 46 | const queriesRef = useRef[] | undefined>(undefined) 47 | // The state that updates when the props references change 48 | const [, setPropsRefsChanged] = useState(0) 49 | // The props keys to be used in effects. 50 | const queryKey = JSON.stringify([collection, queries]) 51 | const queryKeyFromRef = JSON.stringify([ 52 | collectionRef.current, 53 | queriesRef.current 54 | ]) 55 | 56 | // The cursor state. 57 | // 58 | // The cursor used to define the currently loading collection chunk. 59 | // It updates when the next page is requested. 60 | const [cursor, setCursor] = useState< 61 | AnyDoc | undefined 62 | >(undefined) 63 | // The current cursor id 64 | const cursorId = cursor?.ref.id || 'initial' 65 | // Defines the cursor state: never requested, loading or loaded. 66 | const cursorStatesRef = useRef({}) 67 | const [, setCursorStatesChanged] = useState(0) 68 | 69 | // The state exposed to the user 70 | // 71 | // The final result state. 72 | const [result, setResult] = useState< 73 | AnyDoc[] | undefined 74 | >(undefined) 75 | // The error state. 76 | const [error, setError] = useState(undefined) 77 | // True if the query is loaded till the very end. 78 | const [loadedAll, setLoadedAll] = useState(false) 79 | // True if there's a request currently loading or the initial query 80 | // wasn't requested. 81 | const cursorValues = Object.values(cursorStatesRef.current) 82 | const loading = 83 | (cursorValues.length === 0 || !!cursorValues.find(c => c === 'loading')) && 84 | !error 85 | // The function used to trigger a request for the next page. 86 | const loadMore = loadedAll 87 | ? null 88 | : result && cursorStatesRef.current[cursorId] === 'loaded' 89 | ? () => setCursor(result[result.length - 1]) 90 | : undefined 91 | 92 | // Sync the props references and reset the state when they change 93 | useEffect(() => { 94 | // Ignore if the props references are in sync 95 | if (queryKey === queryKeyFromRef) return 96 | 97 | // Sync the props references 98 | collectionRef.current = collection 99 | queriesRef.current = queries 100 | setPropsRefsChanged(Date.now()) 101 | 102 | // Reset the cursor state 103 | setCursor(undefined) 104 | cursorStatesRef.current = {} 105 | setCursorStatesChanged(Date.now()) 106 | 107 | // Reset the exposed state 108 | setResult(undefined) 109 | setError(undefined) 110 | setLoadedAll(false) 111 | }, [queryKey, queryKeyFromRef]) 112 | 113 | // Query the current cursor. 114 | useEffect(() => { 115 | // Skip update if the props references sync is pending, queries is missing, 116 | // or the cursor is already processing. 117 | const propsInSync = queryKey === queryKeyFromRef 118 | const alreadyProcessing = cursorStatesRef.current[cursorId] !== undefined 119 | if ( 120 | !collectionRef.current || 121 | !queriesRef.current || 122 | !propsInSync || 123 | alreadyProcessing 124 | ) 125 | return 126 | 127 | // Maintain the mounted state and ignore results if unmounted. 128 | let unmounted = false 129 | 130 | // Mark the current cursor as loading 131 | cursorStatesRef.current[cursorId] = 'loading' 132 | setCursorStatesChanged(Date.now()) 133 | 134 | query( 135 | collectionRef.current, 136 | queriesRef.current.concat([ 137 | order( 138 | options.field, 139 | options.method || 'asc', 140 | cursor ? [startAfter(cursor)] : [] 141 | ), 142 | limit(options.limit) 143 | ]), 144 | options 145 | ) 146 | .then(newResult => { 147 | if (unmounted) return 148 | 149 | // Mark the current cursor as loaded 150 | cursorStatesRef.current[cursorId] = 'loaded' 151 | setCursorStatesChanged(Date.now()) 152 | 153 | // If the result is empty or less than the requested size, 154 | // then consider the query fully loaded 155 | if (newResult.length === 0 || newResult.length < options.limit) 156 | setLoadedAll(true) 157 | 158 | // Add the requested chunk to the result 159 | setResult((result || []).concat(newResult)) 160 | }) 161 | .catch(error => { 162 | if (unmounted) return 163 | setError(error) 164 | }) 165 | 166 | return () => { 167 | unmounted = true 168 | } 169 | }, [queryKey, queryKeyFromRef, cursorId]) 170 | 171 | return [result, { loading, loadedAll, loadMore, error }] 172 | } 173 | -------------------------------------------------------------------------------- /legacy/useInfiniteQuery/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "module": "../esm/useInfiniteQuery/index.js", 3 | "sideEffects": false 4 | } 5 | -------------------------------------------------------------------------------- /legacy/useInfiniteQuery/test.ts: -------------------------------------------------------------------------------- 1 | import * as testing from '@firebase/testing' 2 | import { act, renderHook } from '@testing-library/react-hooks' 3 | import assert from 'assert' 4 | import { nanoid } from 'nanoid' 5 | import { Collection, collection, Query, set, where } from 'typesaurus' 6 | import { setApp } from 'typesaurus/testing' 7 | import useInfiniteQuery from '.' 8 | import { lockDB } from '../../test/_lib/utils' 9 | 10 | describe('useInfiniteQuery', () => { 11 | type Contact = { ownerId: string; name: string; year: number; birthday: Date } 12 | 13 | const contacts = collection('contacts') 14 | const contactsAlt = collection('contactsAlt') 15 | 16 | let ownerId: string 17 | 18 | beforeEach(async () => { 19 | ownerId = nanoid() 20 | const leshaId = `lesha-${ownerId}` 21 | const sashaId = `sasha-${ownerId}` 22 | const tatiId = `tati-${ownerId}` 23 | 24 | setApp(testing.initializeAdminApp({ projectId: 'project-id' })) 25 | 26 | await Promise.all([ 27 | set(contacts, leshaId, { 28 | ownerId, 29 | name: 'Lesha', 30 | year: 1995, 31 | birthday: new Date(1995, 6, 2) 32 | }), 33 | set(contacts, sashaId, { 34 | ownerId, 35 | name: 'Sasha', 36 | year: 1987, 37 | birthday: new Date(1987, 1, 11) 38 | }), 39 | set(contacts, tatiId, { 40 | ownerId, 41 | name: 'Tati', 42 | year: 1989, 43 | birthday: new Date(1989, 6, 10) 44 | }), 45 | set(contactsAlt, sashaId, { 46 | ownerId, 47 | name: 'Sasha', 48 | year: 1987, 49 | birthday: new Date(1987, 1, 11) 50 | }) 51 | ]) 52 | }) 53 | 54 | it('queries documents', async () => { 55 | const { result, waitFor } = renderHook(() => 56 | useInfiniteQuery(contacts, [where('ownerId', '==', ownerId)], { 57 | field: 'birthday', 58 | method: 'desc', 59 | limit: 2 60 | }) 61 | ) 62 | assert(result.current[0] === undefined) 63 | await waitFor(() => !!result.current[0]) 64 | assert.deepEqual( 65 | result.current[0]!.map(({ data: { name } }) => name), 66 | ['Lesha', 'Tati'] 67 | ) 68 | }) 69 | 70 | it('allows to load more pages', async () => { 71 | const { result, waitFor, waitForNextUpdate } = renderHook(() => 72 | useInfiniteQuery(contacts, [where('ownerId', '==', ownerId)], { 73 | field: 'birthday', 74 | method: 'desc', 75 | limit: 2 76 | }) 77 | ) 78 | await waitFor(() => !!result.current[0]) 79 | assert.deepEqual( 80 | result.current[0]!.map(({ data: { name } }) => name), 81 | ['Lesha', 'Tati'] 82 | ) 83 | assert(!result.current[1].loadedAll) 84 | act(() => result.current[1].loadMore?.()) 85 | await waitForNextUpdate() 86 | assert.deepEqual( 87 | result.current[0]!.map(({ data: { name } }) => name), 88 | ['Lesha', 'Tati', 'Sasha'] 89 | ) 90 | assert(result.current[1].loadMore === null) 91 | assert(result.current[1].loadedAll) 92 | }) 93 | 94 | it('cleans the data and refetch when the collection is changing', async () => { 95 | const initialProps: { collection: Collection } = { 96 | collection: contacts 97 | } 98 | const { result, waitForNextUpdate, rerender } = renderHook( 99 | ({ collection }) => 100 | useInfiniteQuery(collection, [where('ownerId', '==', ownerId)], { 101 | field: 'birthday', 102 | method: 'desc', 103 | limit: 2 104 | }), 105 | { initialProps } 106 | ) 107 | assert(result.current[0] === undefined) 108 | await waitForNextUpdate() 109 | assert(result.current[0]) 110 | rerender({ collection: contactsAlt }) 111 | assert(result.current[0] === undefined) 112 | await waitForNextUpdate() 113 | assert(result.current[0]![0].data.name === 'Sasha') 114 | }) 115 | 116 | it('cleans the data and refetch when the queries are changing', async () => { 117 | const initialProps: { 118 | queries: Query[] 119 | } = { queries: [where('ownerId', '==', ownerId)] } 120 | const { result, rerender, waitForNextUpdate } = renderHook( 121 | ({ queries }) => 122 | useInfiniteQuery(contacts, queries, { 123 | field: 'birthday', 124 | method: 'desc', 125 | limit: 2 126 | }), 127 | { initialProps } 128 | ) 129 | assert(result.current[0] === undefined) 130 | await waitForNextUpdate() 131 | assert(result.current[0]!.length === 2) 132 | rerender({ 133 | queries: [where('ownerId', '==', ownerId), where('name', '==', 'Sasha')] 134 | }) 135 | assert(result.current[0] === undefined) 136 | await waitForNextUpdate() 137 | assert(result.current[0]!.length === 1) 138 | assert(result.current[0]![0].data.name === 'Sasha') 139 | }) 140 | 141 | it('when the queries is undefined the hook waits for it', async () => { 142 | const initialProps: { 143 | queries: Query[] | undefined 144 | } = { queries: undefined } 145 | const { result, rerender, waitFor } = renderHook( 146 | ({ queries }) => 147 | useInfiniteQuery(contacts, queries, { 148 | field: 'birthday', 149 | method: 'desc', 150 | limit: 2 151 | }), 152 | { initialProps } 153 | ) 154 | assert(result.current[0] === undefined) 155 | assert(result.current[1].loading) 156 | 157 | // For some reason, unless there's a timeout, the test becomes flaky. 158 | // I spent hours debugging the issue and have to give up. 159 | // The funny part that putting console.log also fixes the issue, 160 | // so good luck to anyone who would dare to debug this. 161 | await new Promise(resolve => setTimeout(resolve, 1)) 162 | 163 | rerender({ queries: [where('ownerId', '==', ownerId)] }) 164 | 165 | await waitFor(() => !result.current[1].loading) 166 | assert.deepEqual( 167 | result.current[0]!.map(({ data: { name } }) => name), 168 | ['Lesha', 'Tati'] 169 | ) 170 | }) 171 | 172 | it('returns loading state', async () => { 173 | const { result, waitForValueToChange } = renderHook(() => 174 | useInfiniteQuery(contacts, [where('ownerId', '==', ownerId)], { 175 | field: 'birthday', 176 | method: 'desc', 177 | limit: 2 178 | }) 179 | ) 180 | assert(result.current[1].loading) 181 | await waitForValueToChange(() => !result.current[1].loading) 182 | }) 183 | 184 | it('returns error state', async () => { 185 | await lockDB() 186 | const { result, waitForValueToChange } = renderHook(() => 187 | useInfiniteQuery(contacts, [where('ownerId', '==', ownerId)], { 188 | field: 'birthday', 189 | method: 'desc', 190 | limit: 2 191 | }) 192 | ) 193 | assert(result.current[1].loading) 194 | assert(!result.current[1].error) 195 | await waitForValueToChange(() => !result.current[1].loading) 196 | assert(result.current[1].error) 197 | }) 198 | }) 199 | -------------------------------------------------------------------------------- /legacy/useInfiniteScroll/index.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from '../adaptor' 2 | import { InfiniteLoadMoreState } from '../_lib/infinite' 3 | 4 | export default function useInfiniteScroll( 5 | treshold: number, 6 | loadMore: InfiniteLoadMoreState, 7 | element?: HTMLElement 8 | ) { 9 | useEffect(() => { 10 | const target = element || document.body 11 | 12 | const handleScroll = () => { 13 | const scrollY = element ? element.scrollTop : window.scrollY 14 | const pageHeight = target.scrollHeight 15 | const windowHeight = target.clientHeight 16 | const leftHeight = pageHeight - scrollY - windowHeight 17 | 18 | if (leftHeight < windowHeight * treshold) { 19 | loadMore && loadMore() 20 | } 21 | } 22 | 23 | handleScroll() 24 | 25 | const eventTarget = element || document 26 | eventTarget.addEventListener('scroll', handleScroll) 27 | return () => { 28 | eventTarget.removeEventListener('scroll', handleScroll) 29 | } 30 | }, [loadMore, element]) 31 | } 32 | -------------------------------------------------------------------------------- /legacy/useInfiniteScroll/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "module": "../esm/useInfiniteScroll/index.js", 3 | "sideEffects": false 4 | } 5 | -------------------------------------------------------------------------------- /legacy/useOnAll/index.ts: -------------------------------------------------------------------------------- 1 | import { Collection } from 'typesaurus/collection' 2 | import { AnyDoc } from 'typesaurus/doc' 3 | import { onAll, OnAllOptions } from 'typesaurus/onAll' 4 | import { RuntimeEnvironment, ServerTimestampsStrategy } from 'typesaurus/types' 5 | import { useEffect, useState } from '../adaptor' 6 | import { TypesaurusHookResult } from '../types' 7 | 8 | export default function useOnAll< 9 | Model, 10 | Environment extends RuntimeEnvironment | undefined, 11 | ServerTimestamps extends ServerTimestampsStrategy 12 | >( 13 | collection: Collection, 14 | options?: OnAllOptions 15 | ): TypesaurusHookResult< 16 | AnyDoc[] | undefined 17 | > { 18 | const [result, setResult] = useState< 19 | AnyDoc[] | undefined 20 | >(undefined) 21 | const [error, setError] = useState(undefined) 22 | const loading = result === undefined && !error 23 | 24 | const deps = [JSON.stringify(collection)] 25 | useEffect(() => { 26 | if (result) setResult(undefined) 27 | return onAll(collection, setResult, setError, options) 28 | }, deps) 29 | 30 | return [result, { loading, error }] 31 | } 32 | -------------------------------------------------------------------------------- /legacy/useOnAll/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "module": "../esm/useOnAll/index.js", 3 | "sideEffects": false 4 | } 5 | -------------------------------------------------------------------------------- /legacy/useOnAll/test.ts: -------------------------------------------------------------------------------- 1 | import * as testing from '@firebase/testing' 2 | import { act, renderHook } from '@testing-library/react-hooks' 3 | import assert from 'assert' 4 | import { Collection, collection, ref, Ref, remove, set } from 'typesaurus' 5 | import { setApp } from 'typesaurus/testing' 6 | import useOnAll from '.' 7 | import { lockDB } from '../../test/_lib/utils' 8 | 9 | describe('useOnAll', () => { 10 | type Book = { title: string } 11 | type Order = { book: Ref; quantity: number; date?: Date } 12 | const books = collection('books') 13 | const orders = collection('orders') 14 | 15 | const date = new Date(1987, 1, 11) 16 | 17 | beforeEach(async () => { 18 | setApp(testing.initializeAdminApp({ projectId: 'project-id' })) 19 | 20 | await Promise.all([ 21 | set(books, 'sapiens', { title: 'Sapiens' }), 22 | set(books, '22laws', { title: 'The 22 Immutable Laws of Marketing' }), 23 | set(books, 'momtest', { title: 'The Mom Test' }), 24 | remove(books, 'hp1'), 25 | set(orders, 'order1', { 26 | book: ref(books, 'sapiens'), 27 | quantity: 1, 28 | date 29 | }), 30 | set(orders, 'order2', { 31 | book: ref(books, '22laws'), 32 | quantity: 1, 33 | date 34 | }) 35 | ]) 36 | }) 37 | 38 | it('returns all documents', async () => { 39 | const { result, waitForNextUpdate } = renderHook(() => useOnAll(books)) 40 | assert(result.current[0] === undefined) 41 | await waitForNextUpdate() 42 | const [docs] = result.current 43 | assert.deepEqual(docs!.map(({ data: { title } }) => title).sort(), [ 44 | 'Sapiens', 45 | 'The 22 Immutable Laws of Marketing', 46 | 'The Mom Test' 47 | ]) 48 | }) 49 | 50 | it('subscribes to real-time updates', async () => { 51 | const { result, waitForNextUpdate } = renderHook(() => useOnAll(books)) 52 | assert(result.current[0] === undefined) 53 | await waitForNextUpdate() 54 | assert.deepEqual( 55 | result.current[0]!.map(({ data: { title } }) => title).sort(), 56 | ['Sapiens', 'The 22 Immutable Laws of Marketing', 'The Mom Test'] 57 | ) 58 | await act(() => 59 | set(books, 'hp1', { 60 | title: "Harry Potter and the Sorcerer's Stone" 61 | }) 62 | ) 63 | assert.deepEqual( 64 | result.current[0]!.map(({ data: { title } }) => title).sort(), 65 | [ 66 | "Harry Potter and the Sorcerer's Stone", 67 | 'Sapiens', 68 | 'The 22 Immutable Laws of Marketing', 69 | 'The Mom Test' 70 | ] 71 | ) 72 | }) 73 | 74 | it('cleans the data and refetch when the collection is changing', async () => { 75 | const initialProps: { collection: Collection } = { collection: books } 76 | const { result, waitForNextUpdate, rerender } = renderHook( 77 | ({ collection }) => useOnAll(collection), 78 | { initialProps } 79 | ) 80 | assert(result.current[0] === undefined) 81 | await waitForNextUpdate() 82 | assert(result.current[0]) 83 | rerender({ collection: orders }) 84 | assert(result.current[0] === undefined) 85 | await waitForNextUpdate() 86 | assert(result.current[0]![0].data.date.getTime() === date.getTime()) 87 | }) 88 | 89 | it('returns an empty array if the collection is empty', async () => { 90 | const { result, waitForNextUpdate } = renderHook(() => 91 | useOnAll(collection('nope')) 92 | ) 93 | await waitForNextUpdate() 94 | assert.deepEqual(result.current[0], []) 95 | }) 96 | 97 | it('returns loading state', async () => { 98 | const { result, waitForNextUpdate } = renderHook(() => useOnAll(books)) 99 | assert(result.current[1].loading) 100 | await waitForNextUpdate() 101 | assert(!result.current[1].loading) 102 | }) 103 | 104 | it('returns error state', async () => { 105 | await lockDB() 106 | const { result, waitForNextUpdate } = renderHook(() => useOnAll(books)) 107 | assert(result.current[1].loading) 108 | assert(!result.current[1].error) 109 | await waitForNextUpdate() 110 | assert(!result.current[1].loading) 111 | 112 | assert(result.current[1].error) 113 | }) 114 | }) 115 | -------------------------------------------------------------------------------- /legacy/useOnGet/index.ts: -------------------------------------------------------------------------------- 1 | import { Collection } from 'typesaurus/collection' 2 | import { Doc } from 'typesaurus/doc' 3 | import { onGet, OnGetOptions } from 'typesaurus/onGet' 4 | import { Ref } from 'typesaurus/ref' 5 | import { RuntimeEnvironment, ServerTimestampsStrategy } from 'typesaurus/types' 6 | import { useEffect, useState } from '../adaptor' 7 | import { TypesaurusHookResult } from '../types' 8 | 9 | /** 10 | * @param ref - The reference to the document 11 | */ 12 | export default function useOnGet< 13 | Model, 14 | Environment extends RuntimeEnvironment | undefined, 15 | ServerTimestamps extends ServerTimestampsStrategy 16 | >( 17 | ref: Ref | undefined, 18 | options?: OnGetOptions 19 | ): TypesaurusHookResult< 20 | typeof ref extends undefined ? undefined : Doc | null | undefined 21 | > 22 | 23 | /** 24 | * @param collection - The collection to get document from 25 | * @param id - The document id 26 | */ 27 | export default function useOnGet< 28 | Model, 29 | Environment extends RuntimeEnvironment | undefined, 30 | ServerTimestamps extends ServerTimestampsStrategy 31 | >( 32 | collection: Collection, 33 | id: string | undefined, 34 | options?: OnGetOptions 35 | ): TypesaurusHookResult< 36 | typeof id extends undefined ? undefined : Doc | null | undefined 37 | > 38 | 39 | export default function useOnGet< 40 | Model, 41 | Environment extends RuntimeEnvironment | undefined, 42 | ServerTimestamps extends ServerTimestampsStrategy 43 | >( 44 | collectionOrRef: Collection | Ref | undefined, 45 | maybeIdOrOptions?: string | OnGetOptions, 46 | maybeOptions?: OnGetOptions 47 | ) { 48 | let collection: Collection | undefined 49 | let id: string | undefined 50 | let options: OnGetOptions | undefined 51 | 52 | if (collectionOrRef && collectionOrRef.__type__ === 'collection') { 53 | collection = collectionOrRef as Collection 54 | id = maybeIdOrOptions as string | undefined 55 | options = maybeOptions 56 | } else { 57 | const ref = collectionOrRef as Ref | undefined 58 | collection = ref?.collection 59 | id = ref?.id 60 | options = maybeIdOrOptions as 61 | | OnGetOptions 62 | | undefined 63 | } 64 | 65 | const [result, setResult] = useState | null | undefined>(undefined) 66 | const [error, setError] = useState(undefined) 67 | const loading = result === undefined && !error 68 | 69 | const deps = [JSON.stringify(collection), id] 70 | useEffect(() => { 71 | if (result) setResult(undefined) 72 | if (collection && id) 73 | return onGet(collection, id, setResult, setError, options) 74 | }, deps) 75 | 76 | return [result, { loading, error }] 77 | } 78 | -------------------------------------------------------------------------------- /legacy/useOnGet/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "module": "../esm/useOnGet/index.js", 3 | "sideEffects": false 4 | } 5 | -------------------------------------------------------------------------------- /legacy/useOnGet/test.ts: -------------------------------------------------------------------------------- 1 | import * as testing from '@firebase/testing' 2 | import { act, renderHook } from '@testing-library/react-hooks' 3 | import assert from 'assert' 4 | import { add, Collection, collection, Ref, set } from 'typesaurus' 5 | import { setApp } from 'typesaurus/testing' 6 | import useOnGet from '.' 7 | import { lockDB } from '../../test/_lib/utils' 8 | 9 | describe('useOnGet', () => { 10 | type User = { name: string } 11 | const users = collection('users') 12 | const altUsers = collection('altUsers') 13 | 14 | beforeEach(async () => { 15 | setApp(testing.initializeAdminApp({ projectId: 'project-id' })) 16 | }) 17 | 18 | it('returns the requested document', async () => { 19 | const user = await add(users, { name: 'Sasha' }) 20 | const { result, waitForNextUpdate } = renderHook(() => 21 | useOnGet(users, user.id) 22 | ) 23 | assert(result.current[0] === undefined) 24 | await waitForNextUpdate() 25 | const [doc] = result.current 26 | assert(doc!.data.name === 'Sasha') 27 | }) 28 | 29 | it('subscribes to real-time updates', async () => { 30 | const user = await add(users, { name: 'Sasha' }) 31 | const { result, waitForNextUpdate } = renderHook(() => 32 | useOnGet(users, user.id) 33 | ) 34 | assert(result.current[0] === undefined) 35 | await waitForNextUpdate() 36 | assert(result.current[0]!.data.name === 'Sasha') 37 | await act(() => set(users, user.id, { name: 'Alexander' })) 38 | assert(result.current[0]!.data.name === 'Alexander') 39 | }) 40 | 41 | it('accepts refs', async () => { 42 | const user = await add(users, { name: 'Sasha' }) 43 | const { result, waitForNextUpdate } = renderHook(() => useOnGet(user)) 44 | await waitForNextUpdate() 45 | assert(result.current[0]!.data.name === 'Sasha') 46 | }) 47 | 48 | it('returns null if the document is not found', async () => { 49 | const { result, waitForNextUpdate } = renderHook(() => 50 | useOnGet(users, 'nope') 51 | ) 52 | assert(result.current[0] === undefined) 53 | await waitForNextUpdate() 54 | assert(result.current[0] === null) 55 | }) 56 | 57 | it('cleans the data and refetch when the collection is changing', async () => { 58 | const user = await add(users, { name: 'Sasha' }) 59 | await set(altUsers, user.id, { name: 'Alexander' }) 60 | const initialProps: { collection: Collection } = { collection: users } 61 | const { result, waitForNextUpdate, rerender } = renderHook( 62 | ({ collection }) => useOnGet(collection, user.id), 63 | { initialProps } 64 | ) 65 | assert(result.current[0] === undefined) 66 | await waitForNextUpdate() 67 | assert(result.current[0]) 68 | rerender({ collection: altUsers }) 69 | assert(result.current[0] === undefined) 70 | await waitForNextUpdate() 71 | assert(result.current[0]!.data.name === 'Alexander') 72 | }) 73 | 74 | it('cleans the data and refetch when the id is changing', async () => { 75 | const [user, anotherUser] = await Promise.all([ 76 | add(users, { name: 'Sasha' }), 77 | add(users, { name: 'Tati' }) 78 | ]) 79 | const initialProps: { id: string } = { id: user.id } 80 | const { result, waitForNextUpdate, rerender } = renderHook( 81 | ({ id }) => useOnGet(users, id), 82 | { initialProps } 83 | ) 84 | assert(result.current[0] === undefined) 85 | await waitForNextUpdate() 86 | assert(result.current[0]) 87 | rerender({ id: anotherUser.id }) 88 | assert(result.current[0] === undefined) 89 | await waitForNextUpdate() 90 | assert(result.current[0]!.data.name === 'Tati') 91 | }) 92 | 93 | it('when the id is undefined the hook waits for it', async () => { 94 | const user = await add(users, { name: 'Sasha' }) 95 | const initialProps: { id: string | undefined } = { id: undefined } 96 | const { result, rerender, waitForNextUpdate } = renderHook( 97 | ({ id }) => useOnGet(users, id), 98 | { initialProps } 99 | ) 100 | assert(result.current[0] === undefined) 101 | assert(result.current[1].loading) 102 | rerender({ id: user.id }) 103 | await waitForNextUpdate() 104 | const [doc] = result.current 105 | assert(doc!.data.name === 'Sasha') 106 | }) 107 | 108 | it('when the ref is undefined the hook waits for it', async () => { 109 | const user = await add(users, { name: 'Sasha' }) 110 | const initialProps: { ref: Ref | undefined } = { ref: undefined } 111 | const { result, rerender, waitForNextUpdate } = renderHook( 112 | ({ ref }) => useOnGet(ref), 113 | { initialProps } 114 | ) 115 | assert(result.current[0] === undefined) 116 | assert(result.current[1].loading) 117 | rerender({ ref: user }) 118 | await waitForNextUpdate() 119 | const [doc] = result.current 120 | assert(doc!.data.name === 'Sasha') 121 | }) 122 | 123 | it('returns loading state', async () => { 124 | const user = await add(users, { name: 'Sasha' }) 125 | const { result, waitForNextUpdate } = renderHook(() => 126 | useOnGet(users, user.id) 127 | ) 128 | assert(result.current[1].loading) 129 | await waitForNextUpdate() 130 | assert(!result.current[1].loading) 131 | }) 132 | 133 | it('returns error state', async () => { 134 | const user = await add(users, { name: 'Sasha' }) 135 | await lockDB() 136 | const { result, waitForNextUpdate } = renderHook(() => 137 | useOnGet(users, user.id) 138 | ) 139 | assert(result.current[1].loading) 140 | assert(!result.current[1].error) 141 | await waitForNextUpdate() 142 | assert(!result.current[1].loading) 143 | assert(result.current[1].error) 144 | }) 145 | }) 146 | -------------------------------------------------------------------------------- /legacy/useOnGetMany/index.ts: -------------------------------------------------------------------------------- 1 | import { Collection } from 'typesaurus/collection' 2 | import { Doc } from 'typesaurus/doc' 3 | import { OnGetOptions } from 'typesaurus/onGet' 4 | import { onGetMany } from 'typesaurus/onGetMany' 5 | import { RuntimeEnvironment, ServerTimestampsStrategy } from 'typesaurus/types' 6 | import { useEffect, useState } from '../adaptor' 7 | import { TypesaurusHookResult } from '../types' 8 | 9 | export default function useOnGetMany< 10 | Model, 11 | Environment extends RuntimeEnvironment | undefined, 12 | ServerTimestamps extends ServerTimestampsStrategy 13 | >( 14 | collection: Collection, 15 | ids: readonly string[] | undefined, 16 | options?: OnGetOptions 17 | ): TypesaurusHookResult[] | undefined> { 18 | const [result, setResult] = useState[] | undefined>(undefined) 19 | const [error, setError] = useState(undefined) 20 | const loading = result === undefined && !error 21 | 22 | const deps = [JSON.stringify(collection), JSON.stringify(ids)] 23 | useEffect(() => { 24 | if (result) setResult(undefined) 25 | if (ids) return onGetMany(collection, ids, setResult, setError, options) 26 | }, deps) 27 | 28 | return [result, { loading, error }] 29 | } 30 | -------------------------------------------------------------------------------- /legacy/useOnGetMany/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "module": "../esm/useOnGetMany/index.js", 3 | "sideEffects": false 4 | } 5 | -------------------------------------------------------------------------------- /legacy/useOnGetMany/test.ts: -------------------------------------------------------------------------------- 1 | import * as testing from '@firebase/testing' 2 | import { act, renderHook } from '@testing-library/react-hooks' 3 | import assert from 'assert' 4 | import { Collection, collection, set } from 'typesaurus' 5 | import { setApp } from 'typesaurus/testing' 6 | import useOnGetMany from '.' 7 | import { lockDB } from '../../test/_lib/utils' 8 | 9 | describe('useOnGetMany', () => { 10 | type Fruit = { color: string } 11 | const fruits = collection('fruits') 12 | const altFruits = collection('altFruits') 13 | 14 | beforeAll(async () => { 15 | setApp(testing.initializeAdminApp({ projectId: 'project-id' })) 16 | 17 | await Promise.all([ 18 | set(fruits, 'apple', { color: 'green' }), 19 | set(fruits, 'banana', { color: 'yellow' }), 20 | set(fruits, 'orange', { color: 'orange' }) 21 | ]) 22 | }) 23 | 24 | beforeEach(() => { 25 | setApp(testing.initializeAdminApp({ projectId: 'project-id' })) 26 | }) 27 | 28 | it('allows to get multiple docs by id', async () => { 29 | const { result, waitForNextUpdate } = renderHook(() => 30 | useOnGetMany(fruits, ['banana', 'apple', 'banana', 'orange']) 31 | ) 32 | assert(result.current[0] === undefined) 33 | await waitForNextUpdate() 34 | const [docs] = result.current 35 | expect(docs!.length).toBe(4) 36 | expect(docs![0].ref.id).toBe('banana') 37 | expect(docs![1].ref.id).toBe('apple') 38 | expect(docs![2].ref.id).toBe('banana') 39 | expect(docs![3].ref.id).toBe('orange') 40 | }) 41 | 42 | it('subscribes to real-time updates', async () => { 43 | const { result, waitForNextUpdate } = renderHook(() => 44 | useOnGetMany(fruits, ['banana', 'apple', 'banana', 'orange']) 45 | ) 46 | await waitForNextUpdate() 47 | await act(() => set(fruits, 'banana', { color: 'желтый' })) 48 | expect(result.current[0]![0].data.color).toBe('желтый') 49 | }) 50 | 51 | it('cleans the data and refetch when the collection is changing', async () => { 52 | await Promise.all([ 53 | set(altFruits, 'apple', { color: 'зеленый' }), 54 | set(altFruits, 'banana', { color: 'желтый' }), 55 | set(altFruits, 'orange', { color: 'оранжевый' }) 56 | ]) 57 | const initialProps: { collection: Collection } = { collection: fruits } 58 | const { result, waitForNextUpdate, rerender } = renderHook( 59 | ({ collection }) => 60 | useOnGetMany(collection, ['banana', 'apple', 'banana', 'orange']), 61 | { initialProps } 62 | ) 63 | assert(result.current[0] === undefined) 64 | await waitForNextUpdate() 65 | assert(result.current[0]) 66 | rerender({ collection: altFruits }) 67 | assert(result.current[0] === undefined) 68 | await waitForNextUpdate() 69 | assert(result.current[0]![0].data.color === 'желтый') 70 | }) 71 | 72 | it('cleans the data and refetch when the ids is changing', async () => { 73 | const initialProps: { ids: string[] } = { 74 | ids: ['banana', 'apple', 'banana', 'orange'] 75 | } 76 | const { result, waitForNextUpdate, rerender } = renderHook( 77 | ({ ids }) => useOnGetMany(fruits, ids), 78 | { initialProps } 79 | ) 80 | assert(result.current[0] === undefined) 81 | await waitForNextUpdate() 82 | assert(result.current[0]) 83 | rerender({ ids: ['orange'] }) 84 | assert(result.current[0] === undefined) 85 | await waitForNextUpdate() 86 | assert(result.current[0]!.length === 1) 87 | assert(result.current[0]![0].data.color === 'orange') 88 | }) 89 | 90 | it('returns loading state', async () => { 91 | const { result, waitForNextUpdate } = renderHook(() => 92 | useOnGetMany(fruits, ['banana']) 93 | ) 94 | assert(result.current[1].loading) 95 | await waitForNextUpdate() 96 | assert(!result.current[1].loading) 97 | }) 98 | 99 | it('returns error state', async () => { 100 | await lockDB() 101 | const { result, waitForNextUpdate } = renderHook(() => 102 | useOnGetMany(fruits, ['banana']) 103 | ) 104 | assert(result.current[1].loading) 105 | assert(!result.current[1].error) 106 | await waitForNextUpdate() 107 | assert(!result.current[1].loading) 108 | assert(result.current[1].error) 109 | }) 110 | }) 111 | -------------------------------------------------------------------------------- /legacy/useOnQuery/index.ts: -------------------------------------------------------------------------------- 1 | import { Query, RuntimeEnvironment, ServerTimestampsStrategy } from 'typesaurus' 2 | import { Collection } from 'typesaurus/collection' 3 | import { AnyDoc } from 'typesaurus/doc' 4 | import { CollectionGroup } from 'typesaurus/group' 5 | import { onQuery, OnQueryOptions } from 'typesaurus/onQuery' 6 | import { useEffect, useState } from '../adaptor' 7 | import { TypesaurusHookResult } from '../types' 8 | 9 | export default function useOnQuery< 10 | Model, 11 | Environment extends RuntimeEnvironment | undefined, 12 | ServerTimestamps extends ServerTimestampsStrategy 13 | >( 14 | collection: Collection | CollectionGroup, 15 | queries: Query[] | undefined, 16 | options?: OnQueryOptions 17 | ): TypesaurusHookResult< 18 | typeof queries extends undefined 19 | ? undefined 20 | : AnyDoc[] | undefined 21 | > { 22 | const [result, setResult] = useState< 23 | AnyDoc[] | undefined 24 | >(undefined) 25 | const [error, setError] = useState(undefined) 26 | const loading = result === undefined && !error 27 | 28 | const deps = [JSON.stringify(collection), JSON.stringify(queries)] 29 | useEffect(() => { 30 | if (result) setResult(undefined) 31 | if (queries) 32 | return onQuery(collection, queries, setResult, setError, options) 33 | }, deps) 34 | 35 | return [result, { loading, error }] 36 | } 37 | -------------------------------------------------------------------------------- /legacy/useOnQuery/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "module": "../esm/useOnQuery/index.js", 3 | "sideEffects": false 4 | } 5 | -------------------------------------------------------------------------------- /legacy/useOnQuery/test.ts: -------------------------------------------------------------------------------- 1 | import * as testing from '@firebase/testing' 2 | import { act, renderHook } from '@testing-library/react-hooks' 3 | import assert from 'assert' 4 | import { nanoid } from 'nanoid' 5 | import { 6 | add, 7 | Collection, 8 | collection, 9 | limit, 10 | Query, 11 | Ref, 12 | ref, 13 | set, 14 | update, 15 | where 16 | } from 'typesaurus' 17 | import { setApp } from 'typesaurus/testing' 18 | import useOnQuery from '.' 19 | import { lockDB } from '../../test/_lib/utils' 20 | 21 | describe('useOnQuery', () => { 22 | type Contact = { ownerId: string; name: string; year: number; birthday: Date } 23 | type Message = { ownerId: string; author: Ref; text: string } 24 | 25 | const contacts = collection('contacts') 26 | const messages = collection('messages') 27 | 28 | const ownerId = nanoid() 29 | const leshaId = `lesha-${ownerId}` 30 | const sashaId = `sasha-${ownerId}` 31 | const tatiId = `tati-${ownerId}` 32 | 33 | beforeAll(async () => { 34 | setApp(testing.initializeAdminApp({ projectId: 'project-id' })) 35 | 36 | Promise.all([ 37 | set(contacts, leshaId, { 38 | ownerId, 39 | name: 'Lesha', 40 | year: 1995, 41 | birthday: new Date(1995, 6, 2) 42 | }), 43 | set(contacts, tatiId, { 44 | ownerId, 45 | name: 'Tati', 46 | year: 1989, 47 | birthday: new Date(1989, 6, 10) 48 | }), 49 | add(messages, { ownerId, author: ref(contacts, sashaId), text: '+1' }), 50 | add(messages, { ownerId, author: ref(contacts, leshaId), text: '+1' }), 51 | add(messages, { ownerId, author: ref(contacts, tatiId), text: 'wut' }), 52 | add(messages, { ownerId, author: ref(contacts, sashaId), text: 'lul' }) 53 | ]) 54 | }) 55 | 56 | beforeEach(async () => { 57 | await set(contacts, sashaId, { 58 | ownerId, 59 | name: 'Sasha', 60 | year: 1987, 61 | birthday: new Date(1987, 1, 11) 62 | }) 63 | }) 64 | 65 | it('queries documents', async () => { 66 | const { result, waitForNextUpdate } = renderHook(() => 67 | useOnQuery(contacts, [where('ownerId', '==', ownerId)]) 68 | ) 69 | assert(result.current[0] === undefined) 70 | await waitForNextUpdate() 71 | const [docs] = result.current 72 | assert.deepEqual(docs!.map(({ data: { name } }) => name).sort(), [ 73 | 'Lesha', 74 | 'Sasha', 75 | 'Tati' 76 | ]) 77 | }) 78 | 79 | it('subscribes to real-time updates', async () => { 80 | const { result, waitForNextUpdate } = renderHook(() => 81 | useOnQuery(contacts, [where('ownerId', '==', ownerId)]) 82 | ) 83 | await waitForNextUpdate() 84 | await act(() => update(contacts, sashaId, { name: 'Саша' })) 85 | assert.deepEqual( 86 | result.current[0]!.map(({ data: { name } }) => name).sort(), 87 | ['Lesha', 'Tati', 'Саша'] 88 | ) 89 | }) 90 | 91 | it('cleans the data and refetch when the collection is changing', async () => { 92 | const initialProps: { collection: Collection } = { 93 | collection: contacts 94 | } 95 | const { result, waitForNextUpdate, rerender } = renderHook( 96 | ({ collection }) => 97 | useOnQuery(collection, [where('ownerId', '==', ownerId)]), 98 | { initialProps } 99 | ) 100 | assert(result.current[0] === undefined) 101 | await waitForNextUpdate() 102 | assert(result.current[0]) 103 | rerender({ collection: messages }) 104 | assert(result.current[0] === undefined) 105 | await waitForNextUpdate() 106 | assert(typeof result.current[0]![0].data.text === 'string') 107 | }) 108 | 109 | it('cleans the data and refetch when the queries are changing', async () => { 110 | const initialProps: { 111 | queries: Query[] 112 | } = { queries: [where('ownerId', '==', ownerId)] } 113 | const { result, rerender, waitForNextUpdate } = renderHook( 114 | ({ queries }) => useOnQuery(contacts, queries), 115 | { initialProps } 116 | ) 117 | assert(result.current[0] === undefined) 118 | await waitForNextUpdate() 119 | assert(result.current[0]) 120 | rerender({ queries: [where('name', '==', 'Sasha'), limit(1)] }) 121 | assert(result.current[0] === undefined) 122 | await waitForNextUpdate() 123 | assert(result.current[0]!.length === 1) 124 | assert(result.current[0]![0].data.name === 'Sasha') 125 | }) 126 | 127 | it('when the queries is undefined the hook waits for it', async () => { 128 | const initialProps: { 129 | queries: Query[] | undefined 130 | } = { queries: undefined } 131 | const { result, rerender, waitForNextUpdate } = renderHook( 132 | ({ queries }) => useOnQuery(contacts, queries), 133 | { initialProps } 134 | ) 135 | assert(result.current[0] === undefined) 136 | assert(result.current[1].loading) 137 | rerender({ queries: [where('ownerId', '==', ownerId)] }) 138 | await waitForNextUpdate() 139 | const [docs] = result.current 140 | assert.deepEqual(docs!.map(({ data: { name } }) => name).sort(), [ 141 | 'Lesha', 142 | 'Sasha', 143 | 'Tati' 144 | ]) 145 | }) 146 | 147 | it('returns loading state', async () => { 148 | const { result, waitForNextUpdate } = renderHook(() => 149 | useOnQuery(contacts, [where('ownerId', '==', ownerId)]) 150 | ) 151 | assert(result.current[1].loading) 152 | await waitForNextUpdate() 153 | assert(!result.current[1].loading) 154 | }) 155 | 156 | it('returns error state', async () => { 157 | await lockDB() 158 | const { result, waitForNextUpdate } = renderHook(() => 159 | useOnQuery(contacts, [where('ownerId', '==', ownerId)]) 160 | ) 161 | assert(result.current[1].loading) 162 | assert(!result.current[1].error) 163 | await waitForNextUpdate() 164 | assert(!result.current[1].loading) 165 | 166 | assert(result.current[1].error) 167 | }) 168 | }) 169 | -------------------------------------------------------------------------------- /legacy/useQuery/index.ts: -------------------------------------------------------------------------------- 1 | import { Collection } from 'typesaurus/collection' 2 | import { AnyDoc } from 'typesaurus/doc' 3 | import { CollectionGroup } from 'typesaurus/group' 4 | import { query, QueryOptions } from 'typesaurus/query' 5 | import { 6 | Query, 7 | RuntimeEnvironment, 8 | ServerTimestampsStrategy 9 | } from 'typesaurus/types' 10 | import { useEffect, useState } from '../adaptor' 11 | import { TypesaurusHookResult } from '../types' 12 | 13 | export default function useQuery< 14 | Model, 15 | Environment extends RuntimeEnvironment | undefined, 16 | ServerTimestamps extends ServerTimestampsStrategy 17 | >( 18 | collection: Collection | CollectionGroup, 19 | queries: Query[] | undefined, 20 | options?: QueryOptions 21 | ): TypesaurusHookResult< 22 | typeof queries extends undefined 23 | ? undefined 24 | : AnyDoc[] | undefined 25 | > { 26 | const [result, setResult] = useState< 27 | AnyDoc[] | undefined 28 | >(undefined) 29 | const [error, setError] = useState(undefined) 30 | const loading = result === undefined && !error 31 | 32 | const deps = [JSON.stringify(collection), JSON.stringify(queries)] 33 | useEffect(() => { 34 | if (result) setResult(undefined) 35 | if (queries) 36 | query(collection, queries, options).then(setResult).catch(setError) 37 | }, deps) 38 | 39 | return [result, { loading, error }] 40 | } 41 | -------------------------------------------------------------------------------- /legacy/useQuery/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "module": "../esm/useQuery/index.js", 3 | "sideEffects": false 4 | } 5 | -------------------------------------------------------------------------------- /legacy/useQuery/test.ts: -------------------------------------------------------------------------------- 1 | import * as testing from '@firebase/testing' 2 | import { renderHook } from '@testing-library/react-hooks' 3 | import assert from 'assert' 4 | import { nanoid } from 'nanoid' 5 | import { 6 | add, 7 | Collection, 8 | collection, 9 | limit, 10 | Query, 11 | Ref, 12 | ref, 13 | set, 14 | where 15 | } from 'typesaurus' 16 | import { setApp } from 'typesaurus/testing' 17 | import useQuery from '.' 18 | import { lockDB } from '../../test/_lib/utils' 19 | 20 | describe('useQuery', () => { 21 | type Contact = { ownerId: string; name: string; year: number; birthday: Date } 22 | type Message = { ownerId: string; author: Ref; text: string } 23 | 24 | const contacts = collection('contacts') 25 | const messages = collection('messages') 26 | 27 | const ownerId = nanoid() 28 | const leshaId = `lesha-${ownerId}` 29 | const sashaId = `sasha-${ownerId}` 30 | const tatiId = `tati-${ownerId}` 31 | 32 | beforeAll(async () => { 33 | setApp(testing.initializeAdminApp({ projectId: 'project-id' })) 34 | 35 | Promise.all([ 36 | set(contacts, leshaId, { 37 | ownerId, 38 | name: 'Lesha', 39 | year: 1995, 40 | birthday: new Date(1995, 6, 2) 41 | }), 42 | set(contacts, sashaId, { 43 | ownerId, 44 | name: 'Sasha', 45 | year: 1987, 46 | birthday: new Date(1987, 1, 11) 47 | }), 48 | set(contacts, tatiId, { 49 | ownerId, 50 | name: 'Tati', 51 | year: 1989, 52 | birthday: new Date(1989, 6, 10) 53 | }), 54 | add(messages, { ownerId, author: ref(contacts, sashaId), text: '+1' }), 55 | add(messages, { ownerId, author: ref(contacts, leshaId), text: '+1' }), 56 | add(messages, { ownerId, author: ref(contacts, tatiId), text: 'wut' }), 57 | add(messages, { ownerId, author: ref(contacts, sashaId), text: 'lul' }) 58 | ]) 59 | }) 60 | 61 | it('queries documents', async () => { 62 | const { result, waitForNextUpdate } = renderHook(() => 63 | useQuery(contacts, [where('ownerId', '==', ownerId)]) 64 | ) 65 | assert(result.current[0] === undefined) 66 | await waitForNextUpdate() 67 | const [docs] = result.current 68 | assert.deepEqual(docs!.map(({ data: { name } }) => name).sort(), [ 69 | 'Lesha', 70 | 'Sasha', 71 | 'Tati' 72 | ]) 73 | }) 74 | 75 | it('cleans the data and refetch when the collection is changing', async () => { 76 | const initialProps: { collection: Collection } = { 77 | collection: contacts 78 | } 79 | const { result, waitForNextUpdate, rerender } = renderHook( 80 | ({ collection }) => 81 | useQuery(collection, [where('ownerId', '==', ownerId)]), 82 | { initialProps } 83 | ) 84 | assert(result.current[0] === undefined) 85 | await waitForNextUpdate() 86 | assert(result.current[0]) 87 | rerender({ collection: messages }) 88 | assert(result.current[0] === undefined) 89 | await waitForNextUpdate() 90 | assert(typeof result.current[0]![0].data.text === 'string') 91 | }) 92 | 93 | it('cleans the data and refetch when the queries are changing', async () => { 94 | const initialProps: { 95 | queries: Query[] 96 | } = { queries: [where('ownerId', '==', ownerId)] } 97 | const { result, rerender, waitForNextUpdate } = renderHook( 98 | ({ queries }) => useQuery(contacts, queries), 99 | { initialProps } 100 | ) 101 | assert(result.current[0] === undefined) 102 | await waitForNextUpdate() 103 | assert(result.current[0]) 104 | rerender({ queries: [where('name', '==', 'Sasha'), limit(1)] }) 105 | assert(result.current[0] === undefined) 106 | await waitForNextUpdate() 107 | assert(result.current[0]!.length === 1) 108 | assert(result.current[0]![0].data.name === 'Sasha') 109 | }) 110 | 111 | it('when the queries is undefined the hook waits for it', async () => { 112 | const initialProps: { 113 | queries: Query[] | undefined 114 | } = { queries: undefined } 115 | const { result, rerender, waitForNextUpdate } = renderHook( 116 | ({ queries }) => useQuery(contacts, queries), 117 | { initialProps } 118 | ) 119 | assert(result.current[0] === undefined) 120 | assert(result.current[1].loading) 121 | rerender({ queries: [where('ownerId', '==', ownerId)] }) 122 | await waitForNextUpdate() 123 | const [docs] = result.current 124 | assert.deepEqual(docs!.map(({ data: { name } }) => name).sort(), [ 125 | 'Lesha', 126 | 'Sasha', 127 | 'Tati' 128 | ]) 129 | }) 130 | 131 | it('returns loading state', async () => { 132 | const { result, waitForNextUpdate } = renderHook(() => 133 | useQuery(contacts, [where('ownerId', '==', ownerId)]) 134 | ) 135 | assert(result.current[1].loading) 136 | await waitForNextUpdate() 137 | assert(!result.current[1].loading) 138 | }) 139 | 140 | it('returns error state', async () => { 141 | await lockDB() 142 | const { result, waitForNextUpdate } = renderHook(() => 143 | useQuery(contacts, [where('ownerId', '==', ownerId)]) 144 | ) 145 | assert(result.current[1].loading) 146 | assert(!result.current[1].error) 147 | await waitForNextUpdate() 148 | assert(!result.current[1].loading) 149 | 150 | assert(result.current[1].error) 151 | }) 152 | }) 153 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@typesaurus/react", 3 | "version": "6.0.1", 4 | "description": "React Hooks for Typesaurus, type-safe Firestore ODM", 5 | "keywords": [ 6 | "React", 7 | "React Hooks", 8 | "Firebase", 9 | "Firestore", 10 | "TypeScript", 11 | "Typesaurus" 12 | ], 13 | "sideEffects": false, 14 | "main": "index.js", 15 | "module": "index.mjs", 16 | "exports": { 17 | "./package.json": "./package.json", 18 | ".": { 19 | "require": { 20 | "types": "./index.d.ts", 21 | "default": "./index.js" 22 | }, 23 | "import": { 24 | "types": "./index.d.mts", 25 | "default": "./index.mjs" 26 | } 27 | }, 28 | "./useRead": { 29 | "require": { 30 | "types": "./useRead/index.d.ts", 31 | "default": "./useRead/index.js" 32 | }, 33 | "import": { 34 | "types": "./useRead/index.d.mts", 35 | "default": "./useRead/index.mjs" 36 | } 37 | }, 38 | "./useLazyRead": { 39 | "require": { 40 | "types": "./useLazyRead/index.d.ts", 41 | "default": "./useLazyRead/index.js" 42 | }, 43 | "import": { 44 | "types": "./useLazyRead/index.d.mts", 45 | "default": "./useLazyRead/index.mjs" 46 | } 47 | } 48 | }, 49 | "repository": "https://github.com/kossnocorp/typesaurus-react", 50 | "author": "Sasha Koss ", 51 | "license": "MIT", 52 | "devDependencies": { 53 | "@babel/cli": "^7.23.4", 54 | "@babel/core": "^7.23.7", 55 | "@babel/plugin-transform-modules-commonjs": "^7.23.3", 56 | "@babel/preset-env": "^7.23.7", 57 | "@babel/preset-typescript": "^7.23.3", 58 | "@types/node": "^20.10.6", 59 | "@types/react": "^18.2.46", 60 | "babel-plugin-add-import-extension": "^1.6.0", 61 | "preact": "^10.19.3", 62 | "prettier": "^3.1.1", 63 | "react": "^18.2.0", 64 | "tsx": "^4.7.0", 65 | "typesaurus": "^10.0.0-rc.8", 66 | "typescript": "^5.3.3" 67 | }, 68 | "peerDependencies": { 69 | "typesaurus": "^10.0.0" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /scripts/patchPreactopod.ts: -------------------------------------------------------------------------------- 1 | import { readFile, writeFile } from "fs/promises"; 2 | import { resolve } from "path"; 3 | 4 | const path = resolve(process.cwd(), "lib/preactopod"); 5 | const packagePath = resolve(path, "package.json"); 6 | const adapterPath = resolve(path, "adapter"); 7 | const readmePath = resolve(path, "README.md"); 8 | 9 | Promise.all([ 10 | readFile(packagePath, "utf8") 11 | .then(JSON.parse) 12 | .then((packageJSON) => 13 | writeFile( 14 | packagePath, 15 | JSON.stringify(patchPackageJSON(packageJSON), null, 2), 16 | ), 17 | ), 18 | 19 | Promise.all( 20 | ["index.d.mts", "index.d.ts", "index.js", "index.mjs"].map((file) => { 21 | const filePath = resolve(adapterPath, file); 22 | return readFile(filePath, "utf8").then((content) => 23 | writeFile(filePath, content.replace(/react/g, "preact")), 24 | ); 25 | }), 26 | ), 27 | 28 | readFile(readmePath, "utf-8").then((content) => 29 | writeFile( 30 | readmePath, 31 | content.replace(/react/g, "preact").replace(/React/g, "Preact"), 32 | ), 33 | ), 34 | ]).catch((err) => { 35 | console.error(err); 36 | process.exit(1); 37 | }); 38 | 39 | function patchPackageJSON(packageJSON: PackageJSON): PackageJSON { 40 | return Object.assign({}, packageJSON, { 41 | name: "@typesaurus/preact", 42 | description: packageJSON.description.replace("React", "Preact"), 43 | keywords: packageJSON.keywords.reduce( 44 | (acc, keyword) => acc.concat(keyword.replace("React", "Preact")), 45 | [] as string[], 46 | ), 47 | peerDependencies: Object.assign({}, packageJSON.peerDependencies, { 48 | preact: "*", 49 | }), 50 | }); 51 | } 52 | 53 | interface PackageJSON { 54 | name: string; 55 | description: string; 56 | keywords: string[]; 57 | peerDependencies: { [key: string]: string }; 58 | [key: string]: any; 59 | } 60 | -------------------------------------------------------------------------------- /scripts/patchReactopod.ts: -------------------------------------------------------------------------------- 1 | import { writeFile, readFile } from "fs/promises"; 2 | 3 | import { resolve } from "path"; 4 | 5 | const path = resolve(process.cwd(), "lib/reactopod"); 6 | const packagePath = resolve(path, "package.json"); 7 | const adapterPackagePath = resolve(path, "adapter/package.json"); 8 | 9 | Promise.all([ 10 | readFile(packagePath, "utf8") 11 | .then(JSON.parse) 12 | .then((packageJSON) => 13 | writeFile( 14 | packagePath, 15 | JSON.stringify(patchPackageJSON(packageJSON), null, 2) 16 | ) 17 | ), 18 | 19 | writeFile( 20 | adapterPackagePath, 21 | JSON.stringify( 22 | { 23 | main: "./react.js", 24 | module: "./react.mjs", 25 | }, 26 | null, 27 | 2 28 | ) 29 | ), 30 | ]).catch((err) => { 31 | console.error(err); 32 | process.exit(1); 33 | }); 34 | 35 | function patchPackageJSON(packageJSON: PackageJSON): PackageJSON { 36 | return Object.assign({}, packageJSON, { 37 | peerDependencies: Object.assign({}, packageJSON.peerDependencies, { 38 | react: "*", 39 | }), 40 | }); 41 | } 42 | 43 | interface PackageJSON { 44 | peerDependencies: { [key: string]: string }; 45 | [key: string]: any; 46 | } 47 | -------------------------------------------------------------------------------- /src/adapter/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./react.js"; 2 | -------------------------------------------------------------------------------- /src/adapter/preact.ts: -------------------------------------------------------------------------------- 1 | export { 2 | useEffect, 3 | useRef, 4 | useState, 5 | useCallback, 6 | useMemo, 7 | } from "preact/hooks"; 8 | -------------------------------------------------------------------------------- /src/adapter/react.ts: -------------------------------------------------------------------------------- 1 | export { useEffect, useRef, useState, useCallback, useMemo } from "react"; 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export type { TypesaurusReact } from "./types.js"; 2 | export { useRead } from "./useRead/index.js"; 3 | export { useLazyRead, dummyLazyReadHook } from "./useLazyRead/index.js"; 4 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { TypesaurusUtils } from "typesaurus"; 2 | 3 | export namespace TypesaurusReact { 4 | export type HookInput = Type | TypesaurusUtils.Falsy; 5 | 6 | export type HookResult = readonly [ 7 | result: Result, 8 | meta: { loading: boolean; error: unknown } & ExtraMeta, 9 | ]; 10 | 11 | export type HookLazyUse = ( 12 | evaluate?: boolean, 13 | ) => HookResult; 14 | } 15 | -------------------------------------------------------------------------------- /src/useLazyRead/index.ts: -------------------------------------------------------------------------------- 1 | import type { TypesaurusCore as Core } from "typesaurus"; 2 | import { useState, useCallback } from "../adapter/index.js"; 3 | import type { TypesaurusReact as React } from "../types.js"; 4 | import { useRead } from "../index.js"; 5 | 6 | export function useLazyRead< 7 | Request extends Core.Request, 8 | Result, 9 | SubscriptionMeta = undefined, 10 | >( 11 | query: React.HookInput< 12 | | Core.SubscriptionPromise 13 | | Core.SubscriptionPromiseOn 14 | >, 15 | ): React.HookLazyUse { 16 | const [evaluate, setEvaluate] = useState(false); 17 | const result = useRead(evaluate && query); 18 | const lazyEval = useCallback( 19 | (hookEvaluate?: boolean) => { 20 | !evaluate && hookEvaluate !== false && setEvaluate(true); 21 | return result; 22 | }, 23 | [evaluate, result], 24 | ); 25 | return lazyEval; 26 | } 27 | 28 | export const dummyLazyReadHook: React.HookLazyUse = () => [ 29 | undefined, 30 | { loading: true, error: undefined }, 31 | ]; 32 | -------------------------------------------------------------------------------- /src/useRead/index.ts: -------------------------------------------------------------------------------- 1 | import type { TypesaurusCore } from "typesaurus"; 2 | import { useEffect, useState, useMemo } from "../adapter/index.js"; 3 | import type { TypesaurusReact } from "../types.js"; 4 | 5 | export function useRead< 6 | Request extends TypesaurusCore.Request, 7 | Result, 8 | SubscriptionMeta = undefined, 9 | >( 10 | query: TypesaurusReact.HookInput< 11 | | TypesaurusCore.SubscriptionPromise 12 | | TypesaurusCore.SubscriptionPromiseOn 13 | >, 14 | ): TypesaurusReact.HookResult { 15 | const [result, setResult] = useState(undefined); 16 | const [error, setError] = useState(undefined); 17 | 18 | useEffect(() => { 19 | // Use ignore flag to prevent setting state after the hook is unmounted 20 | let ignore = false; 21 | 22 | // Reset the state since the query has changed 23 | setResult(undefined); 24 | setError(undefined); 25 | 26 | // The request is not ready yet 27 | if (!query) return; 28 | 29 | if (typeof query === "function") { 30 | // It's a update subscription function, so we call it 31 | const off = query(((newResult: Result) => { 32 | if (ignore) return; 33 | setResult(newResult); 34 | }) as TypesaurusCore.SubscriptionPromiseCallback< 35 | Result, 36 | SubscriptionMeta 37 | >).catch((newError) => { 38 | if (ignore) return; 39 | setError(newError); 40 | }); 41 | return () => { 42 | ignore = true; 43 | off(); 44 | }; 45 | } else { 46 | // It's a promise, so we await it 47 | query 48 | .then((newResult) => { 49 | if (ignore) return; 50 | setResult(newResult); 51 | }) 52 | .catch((newError) => { 53 | if (ignore) return; 54 | setError(newError); 55 | }); 56 | return () => { 57 | ignore = true; 58 | }; 59 | } 60 | // TODO: Come up with a better way to serialize and identify request 61 | }, [query && JSON.stringify(query.request), setResult, setError]); 62 | 63 | const status = useMemo( 64 | () => ({ loading: result === undefined && !error, error }), 65 | [result, error], 66 | ); 67 | 68 | const tuple = useMemo(() => [result, status] as const, [result, status]); 69 | 70 | return tuple; 71 | } 72 | -------------------------------------------------------------------------------- /test/_lib/utils.ts: -------------------------------------------------------------------------------- 1 | import * as testing from '@firebase/testing' 2 | import { setApp } from 'typesaurus/testing' 3 | 4 | export function lockDB() { 5 | setApp( 6 | testing.initializeTestApp({ 7 | projectId: 'project-id', 8 | auth: { uid: 'user-id' } 9 | }) 10 | ) 11 | return testing.loadFirestoreRules({ 12 | projectId: 'project-id', 13 | rules: ` 14 | rules_version = '2'; 15 | service cloud.firestore { 16 | match /databases/{database}/documents { 17 | match /{document=**} { 18 | allow read, write: if false; 19 | } 20 | } 21 | }` 22 | }) 23 | } 24 | -------------------------------------------------------------------------------- /test/karmaTests.ts: -------------------------------------------------------------------------------- 1 | import * as firebase from 'firebase/app' 2 | import 'firebase/auth' 3 | import 'firebase/firestore' 4 | 5 | const projectId = process.env.FIREBASE_PROJECT_ID 6 | const apiKey = process.env.FIREBASE_API_KEY 7 | 8 | if (!projectId) throw new Error('FIREBASE_PROJECT_ID must be defined') 9 | if (!apiKey) throw new Error('FIREBASE_API_KEY must be defined') 10 | 11 | firebase.initializeApp({ apiKey, projectId }) 12 | 13 | if (process.env.FIRESTORE_EMULATOR_HOST) { 14 | firebase.firestore().settings({ host: process.env.FIRESTORE_EMULATOR_HOST }) 15 | } 16 | 17 | beforeAll(async () => { 18 | const username = process.env.FIREBASE_USERNAME 19 | const password = process.env.FIREBASE_PASSWORD 20 | if (username && password) 21 | return firebase.auth().signInWithEmailAndPassword(username, password) 22 | }) 23 | 24 | const testsContext = require.context('../src/', true, /\/test\.ts$/) 25 | testsContext.keys().forEach(testsContext) 26 | -------------------------------------------------------------------------------- /test/setupJestAfterEnv.ts: -------------------------------------------------------------------------------- 1 | jest.setTimeout(1000) 2 | -------------------------------------------------------------------------------- /test/setupJestLocal.ts: -------------------------------------------------------------------------------- 1 | import { injectTestingAdaptor } from 'typesaurus/testing' 2 | import * as testing from '@firebase/testing' 3 | 4 | injectTestingAdaptor(testing.initializeAdminApp({ projectId: 'project-id' })) 5 | injectTestingAdaptor( 6 | testing.initializeTestApp({ 7 | projectId: 'project-id', 8 | auth: { uid: 'user-id' } 9 | }) 10 | ) 11 | -------------------------------------------------------------------------------- /test/setupJestSystem.ts: -------------------------------------------------------------------------------- 1 | import * as admin from 'firebase-admin' 2 | import serviceKey from '../secrets/key.json' 3 | 4 | admin.initializeApp( 5 | serviceKey && { 6 | credential: admin.credential.cert(serviceKey as admin.ServiceAccount) 7 | } 8 | ) 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2016", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "strict": true, 7 | "exactOptionalPropertyTypes": true, 8 | "noUncheckedIndexedAccess": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "skipLibCheck": true 11 | }, 12 | "include": ["src/**/*.ts", "scripts/**/*.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "compilerOptions": { 4 | "rootDir": "./src", 5 | "sourceMap": true, 6 | "outDir": "lib/reactopod", 7 | "declaration": true, 8 | "emitDeclarationOnly": true 9 | }, 10 | "include": ["src/**/*.ts"] 11 | } 12 | --------------------------------------------------------------------------------