diff --git a/.changeset/real-database-hooks.md b/.changeset/real-database-hooks.md new file mode 100644 index 00000000..0aa62161 --- /dev/null +++ b/.changeset/real-database-hooks.md @@ -0,0 +1,10 @@ +--- +"@tanstack-query-firebase/react": minor +--- + +Add Firebase Realtime Database hooks via `@tanstack-query-firebase/react/database`. + +- Query hooks: `useGetQuery`, `useOnValueQuery`, and child event listeners (`useOnChildAddedQuery`, `useOnChildChangedQuery`, `useOnChildMovedQuery`, `useOnChildRemovedQuery`) +- Mutation hooks: `useSetMutation`, `useUpdateMutation`, `useRemoveMutation`, `usePushMutation`, `useRunTransactionMutation`, and priority helpers +- Connection hooks: `useGoOnlineMutation`, `useGoOfflineMutation` +- OnDisconnect hooks: `useOnDisconnectSetMutation`, `useOnDisconnectUpdateMutation`, `useOnDisconnectRemoveMutation`, `useOnDisconnectCancelMutation`, and priority variants diff --git a/.github/workflows/test-react-firebase-v12.yml b/.github/workflows/test-react-firebase-v12.yml index 610e74dc..2c34ad7b 100644 --- a/.github/workflows/test-react-firebase-v12.yml +++ b/.github/workflows/test-react-firebase-v12.yml @@ -36,7 +36,7 @@ jobs: uses: actions/setup-java@v4 with: distribution: "temurin" - java-version: "17" + java-version: "21" - name: Install Firebase CLI uses: nick-invision/retry@v3 @@ -44,7 +44,7 @@ jobs: timeout_minutes: 10 retry_wait_seconds: 60 max_attempts: 3 - command: npm i -g firebase-tools@latest + command: pnpm add -g firebase-tools@14 - name: Update to Firebase v12 run: | diff --git a/docs.json b/docs.json index 35baeb36..09eba7c4 100644 --- a/docs.json +++ b/docs.json @@ -164,6 +164,101 @@ } ] }, + { + "tab": "react", + "group": "Realtime Database", + "pages": [ + { + "title": "Getting Started", + "href": "/react/database" + }, + { + "group": "Hooks", + "pages": [ + { + "href": "/react/database/hooks/useGetQuery", + "title": "useGetQuery" + }, + { + "href": "/react/database/hooks/useGoOfflineMutation", + "title": "useGoOfflineMutation" + }, + { + "href": "/react/database/hooks/useGoOnlineMutation", + "title": "useGoOnlineMutation" + }, + { + "href": "/react/database/hooks/useOnChildAddedQuery", + "title": "useOnChildAddedQuery" + }, + { + "href": "/react/database/hooks/useOnChildChangedQuery", + "title": "useOnChildChangedQuery" + }, + { + "href": "/react/database/hooks/useOnChildMovedQuery", + "title": "useOnChildMovedQuery" + }, + { + "href": "/react/database/hooks/useOnChildRemovedQuery", + "title": "useOnChildRemovedQuery" + }, + { + "href": "/react/database/hooks/useOnDisconnectCancelMutation", + "title": "useOnDisconnectCancelMutation" + }, + { + "href": "/react/database/hooks/useOnDisconnectRemoveMutation", + "title": "useOnDisconnectRemoveMutation" + }, + { + "href": "/react/database/hooks/useOnDisconnectSetMutation", + "title": "useOnDisconnectSetMutation" + }, + { + "href": "/react/database/hooks/useOnDisconnectSetWithPriorityMutation", + "title": "useOnDisconnectSetWithPriorityMutation" + }, + { + "href": "/react/database/hooks/useOnDisconnectUpdateMutation", + "title": "useOnDisconnectUpdateMutation" + }, + { + "href": "/react/database/hooks/useOnValueQuery", + "title": "useOnValueQuery" + }, + { + "href": "/react/database/hooks/usePushMutation", + "title": "usePushMutation" + }, + { + "href": "/react/database/hooks/useRemoveMutation", + "title": "useRemoveMutation" + }, + { + "href": "/react/database/hooks/useRunTransactionMutation", + "title": "useRunTransactionMutation" + }, + { + "href": "/react/database/hooks/useSetMutation", + "title": "useSetMutation" + }, + { + "href": "/react/database/hooks/useSetPriorityMutation", + "title": "useSetPriorityMutation" + }, + { + "href": "/react/database/hooks/useSetWithPriorityMutation", + "title": "useSetWithPriorityMutation" + }, + { + "href": "/react/database/hooks/useUpdateMutation", + "title": "useUpdateMutation" + } + ] + } + ] + }, { "tab": "react", "group": "Firestore", diff --git a/docs/react-query-firebase.mdx b/docs/react-query-firebase.mdx index 7623a25f..a88d2fb0 100644 --- a/docs/react-query-firebase.mdx +++ b/docs/react-query-firebase.mdx @@ -39,16 +39,13 @@ although initially only React is supported. Altough still in development, the API is designed to work with the newer object-based API of TanStack Query, and also supports newer Firebase services such as Data Connect. -### Realtime Subscription Issues +### Realtime Subscription Hooks -Firebase supports realtime event subscriptions for many of its services, such as Firestore, Realtime Database and -Authentication. +Firebase supports realtime event subscriptions for many of its services, such as Firestore, Realtime Database, and Authentication. -The `react-query-firebase` package had a [limitation](https://github.com/invertase/tanstack-query-firebase/issues/25) whereby the hooks -would not resubscribe whenever a component re-mounted. +The `react-query-firebase` package had a [limitation](https://github.com/invertase/tanstack-query-firebase/issues/25) whereby the hooks would not resubscribe whenever a component re-mounted. -The initial version of `tanstack-query-firebase` currently opts-out of any realtime subscription hooks. This issue will be re-addressed -once the core API is stable supporting all Firebase services. +TanStack Query Firebase now provides Realtime Database listener hooks (such as `useOnValueQuery` and the `useOnChild*` hooks) via `@tanstack-query-firebase/react/database`. Other services may add subscription hooks over time as the core API stabilizes. ## Migration Steps diff --git a/docs/react/database/hooks/useGetQuery.mdx b/docs/react/database/hooks/useGetQuery.mdx new file mode 100644 index 00000000..bda10517 --- /dev/null +++ b/docs/react/database/hooks/useGetQuery.mdx @@ -0,0 +1,18 @@ +--- +title: useGetQuery +--- + +Read data once from a Realtime Database location. + +## Usage + +```tsx +import { ref } from "firebase/database"; +import { useGetQuery } from "@tanstack-query-firebase/react/database"; + +const userRef = ref(database, `users/${uid}`); +const { data: snapshot, isLoading } = useGetQuery(userRef, { + queryKey: ["database", "users", uid], +}); +const value = snapshot?.val(); +``` diff --git a/docs/react/database/hooks/useGoOfflineMutation.mdx b/docs/react/database/hooks/useGoOfflineMutation.mdx new file mode 100644 index 00000000..aa483cb2 --- /dev/null +++ b/docs/react/database/hooks/useGoOfflineMutation.mdx @@ -0,0 +1,14 @@ +--- +title: useGoOfflineMutation +--- + +Disconnect the Realtime Database client from the server. + +## Usage + +```tsx +import { useGoOfflineMutation } from "@tanstack-query-firebase/react/database"; + +const { mutate } = useGoOfflineMutation(database); +mutate(); +``` diff --git a/docs/react/database/hooks/useGoOnlineMutation.mdx b/docs/react/database/hooks/useGoOnlineMutation.mdx new file mode 100644 index 00000000..e310da19 --- /dev/null +++ b/docs/react/database/hooks/useGoOnlineMutation.mdx @@ -0,0 +1,14 @@ +--- +title: useGoOnlineMutation +--- + +Reconnect the Realtime Database client to the server. + +## Usage + +```tsx +import { useGoOnlineMutation } from "@tanstack-query-firebase/react/database"; + +const { mutate } = useGoOnlineMutation(database); +mutate(); +``` diff --git a/docs/react/database/hooks/useOnChildAddedQuery.mdx b/docs/react/database/hooks/useOnChildAddedQuery.mdx new file mode 100644 index 00000000..1ba42acf --- /dev/null +++ b/docs/react/database/hooks/useOnChildAddedQuery.mdx @@ -0,0 +1,17 @@ +--- +title: useOnChildAddedQuery +--- + +Subscribe to `child_added` events on a Realtime Database query. + +## Usage + +```tsx +import { ref } from "firebase/database"; +import { useOnChildAddedQuery } from "@tanstack-query-firebase/react/database"; + +const messagesRef = ref(database, "messages"); +const { data: snapshot } = useOnChildAddedQuery(messagesRef, { + queryKey: ["database", "childAdded", "messages"], +}); +``` diff --git a/docs/react/database/hooks/useOnChildChangedQuery.mdx b/docs/react/database/hooks/useOnChildChangedQuery.mdx new file mode 100644 index 00000000..b98d4acc --- /dev/null +++ b/docs/react/database/hooks/useOnChildChangedQuery.mdx @@ -0,0 +1,17 @@ +--- +title: useOnChildChangedQuery +--- + +Subscribe to `child_changed` events on a Realtime Database query. + +## Usage + +```tsx +import { ref } from "firebase/database"; +import { useOnChildChangedQuery } from "@tanstack-query-firebase/react/database"; + +const messagesRef = ref(database, "messages"); +const { data: snapshot } = useOnChildChangedQuery(messagesRef, { + queryKey: ["database", "childChanged", "messages"], +}); +``` diff --git a/docs/react/database/hooks/useOnChildMovedQuery.mdx b/docs/react/database/hooks/useOnChildMovedQuery.mdx new file mode 100644 index 00000000..17d999c9 --- /dev/null +++ b/docs/react/database/hooks/useOnChildMovedQuery.mdx @@ -0,0 +1,17 @@ +--- +title: useOnChildMovedQuery +--- + +Subscribe to `child_moved` events. Requires a query ordered with `orderByPriority()`. + +## Usage + +```tsx +import { orderByPriority, query, ref } from "firebase/database"; +import { useOnChildMovedQuery } from "@tanstack-query-firebase/react/database"; + +const tasksRef = query(ref(database, "tasks"), orderByPriority()); +const { data: snapshot } = useOnChildMovedQuery(tasksRef, { + queryKey: ["database", "childMoved", "tasks"], +}); +``` diff --git a/docs/react/database/hooks/useOnChildRemovedQuery.mdx b/docs/react/database/hooks/useOnChildRemovedQuery.mdx new file mode 100644 index 00000000..8f30c4ce --- /dev/null +++ b/docs/react/database/hooks/useOnChildRemovedQuery.mdx @@ -0,0 +1,17 @@ +--- +title: useOnChildRemovedQuery +--- + +Subscribe to `child_removed` events on a Realtime Database query. + +## Usage + +```tsx +import { ref } from "firebase/database"; +import { useOnChildRemovedQuery } from "@tanstack-query-firebase/react/database"; + +const messagesRef = ref(database, "messages"); +const { data: snapshot } = useOnChildRemovedQuery(messagesRef, { + queryKey: ["database", "childRemoved", "messages"], +}); +``` diff --git a/docs/react/database/hooks/useOnDisconnectCancelMutation.mdx b/docs/react/database/hooks/useOnDisconnectCancelMutation.mdx new file mode 100644 index 00000000..15335948 --- /dev/null +++ b/docs/react/database/hooks/useOnDisconnectCancelMutation.mdx @@ -0,0 +1,16 @@ +--- +title: useOnDisconnectCancelMutation +--- + +Cancel all pending on-disconnect operations at a location. + +## Usage + +```tsx +import { ref } from "firebase/database"; +import { useOnDisconnectCancelMutation } from "@tanstack-query-firebase/react/database"; + +const statusRef = ref(database, `users/${uid}/status`); +const { mutate } = useOnDisconnectCancelMutation(statusRef); +mutate(); +``` diff --git a/docs/react/database/hooks/useOnDisconnectRemoveMutation.mdx b/docs/react/database/hooks/useOnDisconnectRemoveMutation.mdx new file mode 100644 index 00000000..0cb3f1ff --- /dev/null +++ b/docs/react/database/hooks/useOnDisconnectRemoveMutation.mdx @@ -0,0 +1,16 @@ +--- +title: useOnDisconnectRemoveMutation +--- + +Queue removal of data when the client disconnects. + +## Usage + +```tsx +import { ref } from "firebase/database"; +import { useOnDisconnectRemoveMutation } from "@tanstack-query-firebase/react/database"; + +const sessionRef = ref(database, `sessions/${sessionId}`); +const { mutate } = useOnDisconnectRemoveMutation(sessionRef); +mutate(); +``` diff --git a/docs/react/database/hooks/useOnDisconnectSetMutation.mdx b/docs/react/database/hooks/useOnDisconnectSetMutation.mdx new file mode 100644 index 00000000..f3273146 --- /dev/null +++ b/docs/react/database/hooks/useOnDisconnectSetMutation.mdx @@ -0,0 +1,16 @@ +--- +title: useOnDisconnectSetMutation +--- + +Queue a `set` operation to run when the client disconnects. + +## Usage + +```tsx +import { ref } from "firebase/database"; +import { useOnDisconnectSetMutation } from "@tanstack-query-firebase/react/database"; + +const statusRef = ref(database, `users/${uid}/status`); +const { mutate } = useOnDisconnectSetMutation(statusRef); +mutate("offline"); +``` diff --git a/docs/react/database/hooks/useOnDisconnectSetWithPriorityMutation.mdx b/docs/react/database/hooks/useOnDisconnectSetWithPriorityMutation.mdx new file mode 100644 index 00000000..1a70cf0c --- /dev/null +++ b/docs/react/database/hooks/useOnDisconnectSetWithPriorityMutation.mdx @@ -0,0 +1,16 @@ +--- +title: useOnDisconnectSetWithPriorityMutation +--- + +Queue a `setWithPriority` operation to run when the client disconnects. + +## Usage + +```tsx +import { ref } from "firebase/database"; +import { useOnDisconnectSetWithPriorityMutation } from "@tanstack-query-firebase/react/database"; + +const taskRef = ref(database, "tasks/task-1"); +const { mutate } = useOnDisconnectSetWithPriorityMutation(taskRef); +mutate({ value: { title: "Cleanup" }, priority: 0 }); +``` diff --git a/docs/react/database/hooks/useOnDisconnectUpdateMutation.mdx b/docs/react/database/hooks/useOnDisconnectUpdateMutation.mdx new file mode 100644 index 00000000..5a79c9b0 --- /dev/null +++ b/docs/react/database/hooks/useOnDisconnectUpdateMutation.mdx @@ -0,0 +1,16 @@ +--- +title: useOnDisconnectUpdateMutation +--- + +Queue a multi-path `update` to run when the client disconnects. + +## Usage + +```tsx +import { ref } from "firebase/database"; +import { useOnDisconnectUpdateMutation } from "@tanstack-query-firebase/react/database"; + +const userRef = ref(database, `users/${uid}`); +const { mutate } = useOnDisconnectUpdateMutation(userRef); +mutate({ status: "offline", lastSeen: Date.now() }); +``` diff --git a/docs/react/database/hooks/useOnValueQuery.mdx b/docs/react/database/hooks/useOnValueQuery.mdx new file mode 100644 index 00000000..93f5b132 --- /dev/null +++ b/docs/react/database/hooks/useOnValueQuery.mdx @@ -0,0 +1,17 @@ +--- +title: useOnValueQuery +--- + +Subscribe to value events at a Realtime Database location. + +## Usage + +```tsx +import { ref } from "firebase/database"; +import { useOnValueQuery } from "@tanstack-query-firebase/react/database"; + +const userRef = ref(database, `users/${uid}`); +const { data: snapshot } = useOnValueQuery(userRef, { + queryKey: ["database", "onValue", "users", uid], +}); +``` diff --git a/docs/react/database/hooks/usePushMutation.mdx b/docs/react/database/hooks/usePushMutation.mdx new file mode 100644 index 00000000..751c5dd2 --- /dev/null +++ b/docs/react/database/hooks/usePushMutation.mdx @@ -0,0 +1,16 @@ +--- +title: usePushMutation +--- + +Append a child with an auto-generated key to a list. + +## Usage + +```tsx +import { ref } from "firebase/database"; +import { usePushMutation } from "@tanstack-query-firebase/react/database"; + +const messagesRef = ref(database, "messages"); +const { mutate, data: newRef } = usePushMutation(messagesRef); +mutate({ text: "Hello" }); +``` diff --git a/docs/react/database/hooks/useRemoveMutation.mdx b/docs/react/database/hooks/useRemoveMutation.mdx new file mode 100644 index 00000000..1c7c768b --- /dev/null +++ b/docs/react/database/hooks/useRemoveMutation.mdx @@ -0,0 +1,16 @@ +--- +title: useRemoveMutation +--- + +Delete data at a Realtime Database location. + +## Usage + +```tsx +import { ref } from "firebase/database"; +import { useRemoveMutation } from "@tanstack-query-firebase/react/database"; + +const userRef = ref(database, `users/${uid}`); +const { mutate } = useRemoveMutation(userRef); +mutate(); +``` diff --git a/docs/react/database/hooks/useRunTransactionMutation.mdx b/docs/react/database/hooks/useRunTransactionMutation.mdx new file mode 100644 index 00000000..b721d1d6 --- /dev/null +++ b/docs/react/database/hooks/useRunTransactionMutation.mdx @@ -0,0 +1,19 @@ +--- +title: useRunTransactionMutation +--- + +Run an atomic read-modify-write transaction on a location. + +## Usage + +```tsx +import { ref } from "firebase/database"; +import { useRunTransactionMutation } from "@tanstack-query-firebase/react/database"; + +const counterRef = ref(database, "counters/views"); +const { mutate } = useRunTransactionMutation(counterRef, (current) => { + const count = (current as number | null) ?? 0; + return count + 1; +}); +mutate(); +``` diff --git a/docs/react/database/hooks/useSetMutation.mdx b/docs/react/database/hooks/useSetMutation.mdx new file mode 100644 index 00000000..e4e6d7a7 --- /dev/null +++ b/docs/react/database/hooks/useSetMutation.mdx @@ -0,0 +1,16 @@ +--- +title: useSetMutation +--- + +Write data to a location, replacing any existing data. + +## Usage + +```tsx +import { ref } from "firebase/database"; +import { useSetMutation } from "@tanstack-query-firebase/react/database"; + +const userRef = ref(database, `users/${uid}`); +const { mutate } = useSetMutation(userRef); +mutate({ name: "Ada", score: 1 }); +``` diff --git a/docs/react/database/hooks/useSetPriorityMutation.mdx b/docs/react/database/hooks/useSetPriorityMutation.mdx new file mode 100644 index 00000000..0aeb46d3 --- /dev/null +++ b/docs/react/database/hooks/useSetPriorityMutation.mdx @@ -0,0 +1,16 @@ +--- +title: useSetPriorityMutation +--- + +Set the priority of a Realtime Database location. + +## Usage + +```tsx +import { ref } from "firebase/database"; +import { useSetPriorityMutation } from "@tanstack-query-firebase/react/database"; + +const taskRef = ref(database, "tasks/task-1"); +const { mutate } = useSetPriorityMutation(taskRef); +mutate(1); +``` diff --git a/docs/react/database/hooks/useSetWithPriorityMutation.mdx b/docs/react/database/hooks/useSetWithPriorityMutation.mdx new file mode 100644 index 00000000..b6fd7c66 --- /dev/null +++ b/docs/react/database/hooks/useSetWithPriorityMutation.mdx @@ -0,0 +1,16 @@ +--- +title: useSetWithPriorityMutation +--- + +Write data and priority to a location in one operation. + +## Usage + +```tsx +import { ref } from "firebase/database"; +import { useSetWithPriorityMutation } from "@tanstack-query-firebase/react/database"; + +const taskRef = ref(database, "tasks/task-1"); +const { mutate } = useSetWithPriorityMutation(taskRef); +mutate({ value: { title: "Ship docs" }, priority: 1 }); +``` diff --git a/docs/react/database/hooks/useUpdateMutation.mdx b/docs/react/database/hooks/useUpdateMutation.mdx new file mode 100644 index 00000000..b042235d --- /dev/null +++ b/docs/react/database/hooks/useUpdateMutation.mdx @@ -0,0 +1,16 @@ +--- +title: useUpdateMutation +--- + +Perform a multi-path update without replacing data at the parent reference. + +## Usage + +```tsx +import { ref } from "firebase/database"; +import { useUpdateMutation } from "@tanstack-query-firebase/react/database"; + +const rootRef = ref(database); +const { mutate } = useUpdateMutation(rootRef); +mutate({ "users/ada/score": 10, "users/ada/updatedAt": Date.now() }); +``` diff --git a/docs/react/database/index.mdx b/docs/react/database/index.mdx new file mode 100644 index 00000000..e015811f --- /dev/null +++ b/docs/react/database/index.mdx @@ -0,0 +1,138 @@ +--- +title: Firebase Realtime Database +--- + +Firebase Realtime Database is a cloud-hosted NoSQL database that lets you store and sync data between your users in realtime. + +Before using these hooks, ensure your Firebase project has Realtime Database enabled and your app is initialized with a `Database` instance. + +## Setup + +```ts +import { initializeApp } from "firebase/app"; +import { getDatabase } from "firebase/database"; + +// Initialize your Firebase app +initializeApp({ ... }); + +// Get the Realtime Database instance +const database = getDatabase(app); +``` + +## Importing + +Hooks are exported from the `database` namespace: + +```ts +import { useOnValueQuery } from "@tanstack-query-firebase/react/database"; +``` + +Hooks accept `DatabaseReference` / `Query` values from `ref()` / `query()` and the `Database` instance from `getDatabase()` (obtained at app init, not wrapped by these hooks). + +## One-time reads + +Use `useGetQuery` when you need a single read without a realtime listener: + +```tsx +import { ref } from "firebase/database"; +import { useGetQuery } from "@tanstack-query-firebase/react/database"; + +function UserProfile({ uid }: { uid: string }) { + const userRef = ref(database, `users/${uid}`); + const { data: snapshot, isLoading, isError, error } = useGetQuery(userRef, { + queryKey: ["database", "users", uid], + }); + + if (isLoading) return

Loading...

; + if (isError) return

Error: {error.message}

; + + return

{snapshot?.val()?.name}

; +} +``` + +## Realtime listeners + +Use listener hooks such as `useOnValueQuery` and the `useOnChild*` hooks to subscribe to Realtime Database events. The first snapshot resolves the query; later snapshots update the TanStack Query cache. + +```tsx +import { ref } from "firebase/database"; +import { useOnValueQuery } from "@tanstack-query-firebase/react/database"; + +function LiveScoreboard({ gameId }: { gameId: string }) { + const scoreRef = ref(database, `games/${gameId}/score`); + const { data: snapshot } = useOnValueQuery(scoreRef, { + queryKey: ["database", "onValue", "games", gameId, "score"], + }); + + return

Score: {snapshot?.val() ?? 0}

; +} +``` + +Pass Firebase [`ListenOptions`](https://firebase.google.com/docs/reference/js/database.listoptions) via the `database` option (for example `{ onlyOnce: true }`): + +```tsx +useOnValueQuery(scoreRef, { + queryKey: ["database", "onValue", "games", gameId, "score"], + database: { onlyOnce: true }, +}); +``` + + +Components sharing the same `queryKey` share one TanStack Query cache entry. Use a stable, unique `queryKey` per path to avoid duplicate Firebase listeners. + + +## Mutations + +Write hooks follow the same pattern as other TanStack Query Firebase modules. Pass a `DatabaseReference` when creating the hook, then call `mutate` with the payload: + +```tsx +import { ref } from "firebase/database"; +import { useSetMutation } from "@tanstack-query-firebase/react/database"; + +function UpdateUser({ uid }: { uid: string }) { + const userRef = ref(database, `users/${uid}`); + const { mutate, isPending } = useSetMutation(userRef); + + return ( + + ); +} +``` + +## Connection and on-disconnect hooks + +- `useGoOnlineMutation` / `useGoOfflineMutation` accept a `Database` instance to control client connectivity. +- `useOnDisconnect*` hooks queue writes that run when the client disconnects from the Realtime Database server. + +## Available hooks + +### Queries + +- [`useGetQuery`](/react/database/hooks/useGetQuery) — one-time read via `get` +- [`useOnValueQuery`](/react/database/hooks/useOnValueQuery) — subscribe to value changes +- [`useOnChildAddedQuery`](/react/database/hooks/useOnChildAddedQuery) — subscribe to `child_added` +- [`useOnChildChangedQuery`](/react/database/hooks/useOnChildChangedQuery) — subscribe to `child_changed` +- [`useOnChildMovedQuery`](/react/database/hooks/useOnChildMovedQuery) — subscribe to `child_moved` +- [`useOnChildRemovedQuery`](/react/database/hooks/useOnChildRemovedQuery) — subscribe to `child_removed` + +### Mutations + +- [`useSetMutation`](/react/database/hooks/useSetMutation) — replace data at a location +- [`useUpdateMutation`](/react/database/hooks/useUpdateMutation) — multi-path update +- [`useRemoveMutation`](/react/database/hooks/useRemoveMutation) — delete data +- [`usePushMutation`](/react/database/hooks/usePushMutation) — append with an auto-generated key +- [`useRunTransactionMutation`](/react/database/hooks/useRunTransactionMutation) — atomic read-modify-write +- [`useSetPriorityMutation`](/react/database/hooks/useSetPriorityMutation) — set node priority +- [`useSetWithPriorityMutation`](/react/database/hooks/useSetWithPriorityMutation) — write value and priority together +- [`useGoOnlineMutation`](/react/database/hooks/useGoOnlineMutation) — reconnect to the server +- [`useGoOfflineMutation`](/react/database/hooks/useGoOfflineMutation) — disconnect from the server +- [`useOnDisconnectSetMutation`](/react/database/hooks/useOnDisconnectSetMutation) — queue a disconnect `set` +- [`useOnDisconnectUpdateMutation`](/react/database/hooks/useOnDisconnectUpdateMutation) — queue a disconnect `update` +- [`useOnDisconnectRemoveMutation`](/react/database/hooks/useOnDisconnectRemoveMutation) — queue a disconnect `remove` +- [`useOnDisconnectCancelMutation`](/react/database/hooks/useOnDisconnectCancelMutation) — cancel queued disconnect operations +- [`useOnDisconnectSetWithPriorityMutation`](/react/database/hooks/useOnDisconnectSetWithPriorityMutation) — queue a disconnect `setWithPriority` diff --git a/docs/react/index.mdx b/docs/react/index.mdx index df819387..ce906320 100644 --- a/docs/react/index.mdx +++ b/docs/react/index.mdx @@ -89,4 +89,6 @@ function MyApplication() { } ``` -TanStack Query Firebase provides hooks for all Firebase services, supporting both mutations and queries. +TanStack Query Firebase provides hooks for Firebase services including Authentication, Firestore, Realtime Database, and Data Connect, supporting both mutations and queries. + +See the [Realtime Database documentation](/react/database) for listener and write hooks via `@tanstack-query-firebase/react/database`. diff --git a/packages/react/package.json b/packages/react/package.json index cbfbbccb..b97b925e 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -29,6 +29,10 @@ "./data-connect": { "import": "./dist/data-connect/index.js", "types": "./dist/data-connect/index.d.ts" + }, + "./database": { + "import": "./dist/database/index.js", + "types": "./dist/database/index.d.ts" } }, "author": { diff --git a/packages/react/src/database/createSubscriptionQueryFn.test.ts b/packages/react/src/database/createSubscriptionQueryFn.test.ts new file mode 100644 index 00000000..3bf3ac59 --- /dev/null +++ b/packages/react/src/database/createSubscriptionQueryFn.test.ts @@ -0,0 +1,89 @@ +import { QueryClient } from "@tanstack/react-query"; +import { describe, expect, test, vi } from "vitest"; +import { createDatabaseSubscriptionQueryFn } from "./createSubscriptionQueryFn"; + +describe("createDatabaseSubscriptionQueryFn", () => { + test("resolves on first value and updates cache on subsequent values", async () => { + const client = new QueryClient(); + const queryKey = ["database", "subscription-test"]; + let onNext: ((value: number) => void) | undefined; + let onError: ((error: Error) => void) | undefined; + + const subscribe = vi.fn((handlers) => { + onNext = handlers.onNext; + onError = handlers.onError; + return vi.fn(); + }); + + const queryFn = createDatabaseSubscriptionQueryFn(subscribe); + const controller = new AbortController(); + + const promise = queryFn({ + client, + queryKey, + signal: controller.signal, + meta: undefined, + }); + + onNext?.(1); + await expect(promise).resolves.toBe(1); + + onNext?.(2); + expect(client.getQueryData(queryKey)).toBe(2); + + onError?.(new Error("listener failed")); + expect(onError).toBeDefined(); + }); + + test("rejects on first listener error and invalidates on later errors", async () => { + const client = new QueryClient(); + const queryKey = ["database", "subscription-error"]; + let onNext: ((value: number) => void) | undefined; + let onError: ((error: Error) => void) | undefined; + + const subscribe = vi.fn((handlers) => { + onNext = handlers.onNext; + onError = handlers.onError; + return vi.fn(); + }); + + const queryFn = createDatabaseSubscriptionQueryFn(subscribe); + const invalidateSpy = vi.spyOn(client, "invalidateQueries"); + + const promise = queryFn({ + client, + queryKey, + signal: new AbortController().signal, + meta: undefined, + }); + + onError?.(new Error("first failure")); + + await expect(promise).rejects.toThrow("first failure"); + + onNext?.(1); + onError?.(new Error("second failure")); + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey }); + }); + + test("unsubscribes when the fetch is aborted", async () => { + const client = new QueryClient(); + const unsubscribe = vi.fn(); + const subscribe = vi.fn(() => unsubscribe); + + const queryFn = createDatabaseSubscriptionQueryFn(subscribe); + const controller = new AbortController(); + + queryFn({ + client, + queryKey: ["database", "abort-test"], + signal: controller.signal, + meta: undefined, + }); + + controller.abort(); + + expect(unsubscribe).toHaveBeenCalled(); + }); +}); diff --git a/packages/react/src/database/createSubscriptionQueryFn.ts b/packages/react/src/database/createSubscriptionQueryFn.ts new file mode 100644 index 00000000..507b22ce --- /dev/null +++ b/packages/react/src/database/createSubscriptionQueryFn.ts @@ -0,0 +1,60 @@ +import type { + QueryFunction, + QueryFunctionContext, +} from "@tanstack/react-query"; +import type { Unsubscribe } from "firebase/database"; + +type ListenerHandlers = { + onNext: (data: TData) => void; + onError: (error: Error) => void; +}; + +/** + * Wraps a Firebase Realtime Database listener in a TanStack Query `queryFn`. + * + * - Resolves the initial fetch promise on the first snapshot. + * - Pushes subsequent snapshots into the cache via `setQueryData`. + * - Unsubscribes when the query fetch is aborted (unmount / cancel). + */ +export function createDatabaseSubscriptionQueryFn( + subscribe: (handlers: ListenerHandlers) => Unsubscribe, +): QueryFunction { + return (context: QueryFunctionContext) => { + const { client, queryKey, signal } = context; + let firstRun = true; + + return new Promise((resolve, reject) => { + const unsubscribe = subscribe({ + onNext: (data) => { + if (firstRun) { + firstRun = false; + resolve(data); + return; + } + client.setQueryData(queryKey, data); + }, + onError: (error) => { + if (firstRun) { + firstRun = false; + reject(error); + return; + } + client.invalidateQueries({ queryKey }); + }, + }); + + signal.addEventListener( + "abort", + () => { + unsubscribe(); + }, + { once: true }, + ); + }); + }; +} + +export const databaseSubscriptionQueryDefaults = { + staleTime: Number.POSITIVE_INFINITY, + refetchOnMount: "always" as const, +}; diff --git a/packages/react/src/database/index.ts b/packages/react/src/database/index.ts index b655792b..57018df0 100644 --- a/packages/react/src/database/index.ts +++ b/packages/react/src/database/index.ts @@ -1,5 +1,28 @@ -// useRefQuery -// useRefSetMutation -// useRefSetPriorityMutation -// useRefSetWithPriorityMutation -// useRefUpdateMutation +// Firebase Realtime Database (`firebase/database`) +// Reference: https://firebase.google.com/docs/reference/js/database +// OnDisconnect methods: https://firebase.google.com/docs/reference/js/database.ondisconnect +// +// Hooks accept `DatabaseReference` / `Query` from `ref()` / `query()` and a `Database` +// instance from `getDatabase()` (obtained at app init, not wrapped). + +export type { DatabaseMutationOptions, DatabaseUseQueryOptions } from "./types"; +export { useGetQuery } from "./useGetQuery"; +export { useGoOfflineMutation } from "./useGoOfflineMutation"; +export { useGoOnlineMutation } from "./useGoOnlineMutation"; +export { useOnChildAddedQuery } from "./useOnChildAddedQuery"; +export { useOnChildChangedQuery } from "./useOnChildChangedQuery"; +export { useOnChildMovedQuery } from "./useOnChildMovedQuery"; +export { useOnChildRemovedQuery } from "./useOnChildRemovedQuery"; +export { useOnDisconnectCancelMutation } from "./useOnDisconnectCancelMutation"; +export { useOnDisconnectRemoveMutation } from "./useOnDisconnectRemoveMutation"; +export { useOnDisconnectSetMutation } from "./useOnDisconnectSetMutation"; +export { useOnDisconnectSetWithPriorityMutation } from "./useOnDisconnectSetWithPriorityMutation"; +export { useOnDisconnectUpdateMutation } from "./useOnDisconnectUpdateMutation"; +export { useOnValueQuery } from "./useOnValueQuery"; +export { usePushMutation } from "./usePushMutation"; +export { useRemoveMutation } from "./useRemoveMutation"; +export { useRunTransactionMutation } from "./useRunTransactionMutation"; +export { useSetMutation } from "./useSetMutation"; +export { useSetPriorityMutation } from "./useSetPriorityMutation"; +export { useSetWithPriorityMutation } from "./useSetWithPriorityMutation"; +export { useUpdateMutation } from "./useUpdateMutation"; diff --git a/packages/react/src/database/types.ts b/packages/react/src/database/types.ts new file mode 100644 index 00000000..5071a5a6 --- /dev/null +++ b/packages/react/src/database/types.ts @@ -0,0 +1,32 @@ +import type { + UseMutationOptions, + UseQueryOptions, +} from "@tanstack/react-query"; +import type { ListenOptions } from "firebase/database"; + +/** + * TanStack Query options for Realtime Database read hooks. + * + * @remarks + * `queryKey` is required (same as Firestore hooks). For listener hooks (`useOnValueQuery`, etc.), + * pass Firebase {@link https://firebase.google.com/docs/reference/js/database.listoptions | ListenOptions} + * via `database` (e.g. `{ onlyOnce: true }`). + */ +export type DatabaseUseQueryOptions = Omit< + UseQueryOptions, + "queryFn" +> & { + database?: ListenOptions; +}; + +/** + * TanStack Query options for Realtime Database mutation hooks. + * + * @remarks + * Omits `mutationFn` because the hook wires the Firebase SDK call. + */ +export type DatabaseMutationOptions< + TData = unknown, + TError = Error, + TVariables = void, +> = Omit, "mutationFn">; diff --git a/packages/react/src/database/useDatabaseSubscriptionQuery.ts b/packages/react/src/database/useDatabaseSubscriptionQuery.ts new file mode 100644 index 00000000..5e46a409 --- /dev/null +++ b/packages/react/src/database/useDatabaseSubscriptionQuery.ts @@ -0,0 +1,36 @@ +import { useQuery } from "@tanstack/react-query"; +import type { FirebaseError } from "firebase/app"; +import type { + DataSnapshot, + ListenOptions, + Query, + Unsubscribe, +} from "firebase/database"; +import { + createDatabaseSubscriptionQueryFn, + databaseSubscriptionQueryDefaults, +} from "./createSubscriptionQueryFn"; +import type { DatabaseUseQueryOptions } from "./types"; + +type SubscribeToQuery = ( + query: Query, + onNext: (snapshot: DataSnapshot) => void, + onError: (error: Error) => void, + options?: ListenOptions, +) => Unsubscribe; + +export function useDatabaseSubscriptionQuery( + query: Query, + subscribeToQuery: SubscribeToQuery, + options: DatabaseUseQueryOptions, +) { + const { database: listenOptions, ...queryOptions } = options; + + return useQuery({ + ...databaseSubscriptionQueryDefaults, + ...queryOptions, + queryFn: createDatabaseSubscriptionQueryFn((handlers) => + subscribeToQuery(query, handlers.onNext, handlers.onError, listenOptions), + ), + }); +} diff --git a/packages/react/src/database/useGetQuery.test.tsx b/packages/react/src/database/useGetQuery.test.tsx new file mode 100644 index 00000000..341b53de --- /dev/null +++ b/packages/react/src/database/useGetQuery.test.tsx @@ -0,0 +1,49 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { get, ref, set } from "firebase/database"; +import { beforeEach, describe, expect, test } from "vitest"; +import { database, wipeDatabase } from "~/testing-utils"; +import { queryClient, wrapper } from "../../utils"; +import { useGetQuery } from "./useGetQuery"; + +describe("useGetQuery", () => { + beforeEach(async () => { + queryClient.clear(); + await wipeDatabase(); + }); + + test("fetches data at a reference", async () => { + const dbRef = ref(database, "tests/useGetQuery"); + await set(dbRef, { foo: "bar" }); + + const { result } = renderHook( + () => + useGetQuery(dbRef, { + queryKey: ["database", "get", "tests/useGetQuery"], + }), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data?.val()).toEqual({ foo: "bar" }); + }); + + test("returns an error when the read fails", async () => { + const dbRef = ref(database, "tests/useGetQuery-missing"); + + const { result } = renderHook( + () => + useGetQuery(dbRef, { + queryKey: ["database", "get", "tests/useGetQuery-missing"], + retry: false, + }), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const snapshot = await get(dbRef); + expect(snapshot.exists()).toBe(false); + expect(result.current.data?.exists()).toBe(false); + }); +}); diff --git a/packages/react/src/database/useGetQuery.ts b/packages/react/src/database/useGetQuery.ts new file mode 100644 index 00000000..f0488130 --- /dev/null +++ b/packages/react/src/database/useGetQuery.ts @@ -0,0 +1,34 @@ +import { useQuery } from "@tanstack/react-query"; +import type { FirebaseError } from "firebase/app"; +import { type DataSnapshot, get, type Query } from "firebase/database"; +import type { DatabaseUseQueryOptions } from "./types"; + +/** + * Hook to read data once from a Realtime Database location. + * + * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.md#get | get}. + * + * @param query - A `DatabaseReference` or `Query` (from `ref()` / `query()`). + * @param options - TanStack Query options; `queryKey` is required. + * @returns TanStack Query result with a `DataSnapshot`. + * + * @example + * ```tsx + * const userRef = ref(database, `users/${uid}`); + * const { data: snapshot, isLoading } = useGetQuery(userRef, { + * queryKey: ["database", "users", uid], + * }); + * const value = snapshot?.val(); + * ``` + */ +export function useGetQuery( + query: Query, + options: DatabaseUseQueryOptions, +) { + const { database: _listenOptions, ...queryOptions } = options; + + return useQuery({ + ...queryOptions, + queryFn: () => get(query), + }); +} diff --git a/packages/react/src/database/useGoOfflineMutation.test.tsx b/packages/react/src/database/useGoOfflineMutation.test.tsx new file mode 100644 index 00000000..10d540be --- /dev/null +++ b/packages/react/src/database/useGoOfflineMutation.test.tsx @@ -0,0 +1,24 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, test } from "vitest"; +import { database, wipeDatabase } from "~/testing-utils"; +import { queryClient, wrapper } from "../../utils"; +import { useGoOfflineMutation } from "./useGoOfflineMutation"; + +describe("useGoOfflineMutation", () => { + beforeEach(async () => { + queryClient.clear(); + await wipeDatabase(); + }); + + test("calls goOffline without throwing", async () => { + const { result } = renderHook(() => useGoOfflineMutation(database), { + wrapper, + }); + + await act(async () => { + result.current.mutate(); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + }); +}); diff --git a/packages/react/src/database/useGoOfflineMutation.ts b/packages/react/src/database/useGoOfflineMutation.ts new file mode 100644 index 00000000..03c06f97 --- /dev/null +++ b/packages/react/src/database/useGoOfflineMutation.ts @@ -0,0 +1,25 @@ +import { useMutation } from "@tanstack/react-query"; +import type { FirebaseError } from "firebase/app"; +import { type Database, goOffline } from "firebase/database"; +import type { DatabaseMutationOptions } from "./types"; + +/** + * Hook to disconnect the Realtime Database client from the server. + * + * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.md#gooffline | goOffline}. + * + * @param database - The `Database` instance from `getDatabase()`. + * @param options - TanStack Mutation options. + * @returns TanStack Mutation result. Call `mutate()` to go offline. + */ +export function useGoOfflineMutation( + database: Database, + options?: DatabaseMutationOptions, +) { + return useMutation({ + ...options, + mutationFn: async () => { + goOffline(database); + }, + }); +} diff --git a/packages/react/src/database/useGoOnlineMutation.test.tsx b/packages/react/src/database/useGoOnlineMutation.test.tsx new file mode 100644 index 00000000..da6c922f --- /dev/null +++ b/packages/react/src/database/useGoOnlineMutation.test.tsx @@ -0,0 +1,24 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, test } from "vitest"; +import { database, wipeDatabase } from "~/testing-utils"; +import { queryClient, wrapper } from "../../utils"; +import { useGoOnlineMutation } from "./useGoOnlineMutation"; + +describe("useGoOnlineMutation", () => { + beforeEach(async () => { + queryClient.clear(); + await wipeDatabase(); + }); + + test("calls goOnline without throwing", async () => { + const { result } = renderHook(() => useGoOnlineMutation(database), { + wrapper, + }); + + await act(async () => { + result.current.mutate(); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + }); +}); diff --git a/packages/react/src/database/useGoOnlineMutation.ts b/packages/react/src/database/useGoOnlineMutation.ts new file mode 100644 index 00000000..2fb57f8f --- /dev/null +++ b/packages/react/src/database/useGoOnlineMutation.ts @@ -0,0 +1,25 @@ +import { useMutation } from "@tanstack/react-query"; +import type { FirebaseError } from "firebase/app"; +import { type Database, goOnline } from "firebase/database"; +import type { DatabaseMutationOptions } from "./types"; + +/** + * Hook to reconnect the Realtime Database client to the server. + * + * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.md#goonline | goOnline}. + * + * @param database - The `Database` instance from `getDatabase()`. + * @param options - TanStack Mutation options. + * @returns TanStack Mutation result. Call `mutate()` to go online. + */ +export function useGoOnlineMutation( + database: Database, + options?: DatabaseMutationOptions, +) { + return useMutation({ + ...options, + mutationFn: async () => { + goOnline(database); + }, + }); +} diff --git a/packages/react/src/database/useOnChildAddedQuery.test.tsx b/packages/react/src/database/useOnChildAddedQuery.test.tsx new file mode 100644 index 00000000..3c204b22 --- /dev/null +++ b/packages/react/src/database/useOnChildAddedQuery.test.tsx @@ -0,0 +1,30 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { child, ref, set } from "firebase/database"; +import { beforeEach, describe, expect, test } from "vitest"; +import { database, wipeDatabase } from "~/testing-utils"; +import { queryClient, wrapper } from "../../utils"; +import { useOnChildAddedQuery } from "./useOnChildAddedQuery"; + +describe("useOnChildAddedQuery", () => { + beforeEach(async () => { + queryClient.clear(); + await wipeDatabase(); + }); + + test("receives a child_added snapshot", async () => { + const listRef = ref(database, "tests/useOnChildAddedQuery"); + await set(child(listRef, "a"), { name: "alpha" }); + + const { result } = renderHook( + () => + useOnChildAddedQuery(listRef, { + queryKey: ["database", "onChildAdded", "tests/useOnChildAddedQuery"], + }), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.key).toBe("a"); + expect(result.current.data?.val()).toEqual({ name: "alpha" }); + }); +}); diff --git a/packages/react/src/database/useOnChildAddedQuery.ts b/packages/react/src/database/useOnChildAddedQuery.ts new file mode 100644 index 00000000..a682bbb4 --- /dev/null +++ b/packages/react/src/database/useOnChildAddedQuery.ts @@ -0,0 +1,24 @@ +import type { FirebaseError } from "firebase/app"; +import { type DataSnapshot, onChildAdded, type Query } from "firebase/database"; +import type { DatabaseUseQueryOptions } from "./types"; +import { useDatabaseSubscriptionQuery } from "./useDatabaseSubscriptionQuery"; + +/** + * Hook to subscribe to `child_added` events on a Realtime Database query. + * + * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.md#onchildadded | onChildAdded}. + * + * @param query - A `DatabaseReference` or `Query` (typically a list path). + * @param options - TanStack Query options; `queryKey` is required. + * @returns TanStack Query result with the latest child `DataSnapshot`. + * + * @remarks + * Fires when a child is added. Does not replay existing children unless you attach before + * writes occur. See {@link useOnValueQuery} to observe the full list. + */ +export function useOnChildAddedQuery( + query: Query, + options: DatabaseUseQueryOptions, +) { + return useDatabaseSubscriptionQuery(query, onChildAdded, options); +} diff --git a/packages/react/src/database/useOnChildChangedQuery.test.tsx b/packages/react/src/database/useOnChildChangedQuery.test.tsx new file mode 100644 index 00000000..0f455de3 --- /dev/null +++ b/packages/react/src/database/useOnChildChangedQuery.test.tsx @@ -0,0 +1,36 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { child, ref, set } from "firebase/database"; +import { beforeEach, describe, expect, test } from "vitest"; +import { database, wipeDatabase } from "~/testing-utils"; +import { queryClient, wrapper } from "../../utils"; +import { useOnChildChangedQuery } from "./useOnChildChangedQuery"; + +describe("useOnChildChangedQuery", () => { + beforeEach(async () => { + queryClient.clear(); + await wipeDatabase(); + }); + + test("receives child_changed updates", async () => { + const listRef = ref(database, "tests/useOnChildChangedQuery"); + const childRef = child(listRef, "a"); + + const { result } = renderHook( + () => + useOnChildChangedQuery(listRef, { + queryKey: [ + "database", + "onChildChanged", + "tests/useOnChildChangedQuery", + ], + }), + { wrapper }, + ); + + await set(childRef, { name: "alpha" }); + await set(childRef, { name: "beta" }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.val()).toEqual({ name: "beta" }); + }); +}); diff --git a/packages/react/src/database/useOnChildChangedQuery.ts b/packages/react/src/database/useOnChildChangedQuery.ts new file mode 100644 index 00000000..f7b411a6 --- /dev/null +++ b/packages/react/src/database/useOnChildChangedQuery.ts @@ -0,0 +1,27 @@ +import type { FirebaseError } from "firebase/app"; +import { + type DataSnapshot, + onChildChanged, + type Query, +} from "firebase/database"; +import type { DatabaseUseQueryOptions } from "./types"; +import { useDatabaseSubscriptionQuery } from "./useDatabaseSubscriptionQuery"; + +/** + * Hook to subscribe to `child_changed` events on a Realtime Database query. + * + * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.md#onchildchanged | onChildChanged}. + * + * @param query - A `DatabaseReference` or `Query`. + * @param options - TanStack Query options; `queryKey` is required. + * @returns TanStack Query result with the latest changed child `DataSnapshot`. + * + * @remarks + * Fires when an existing child is modified, not when a child is first created. + */ +export function useOnChildChangedQuery( + query: Query, + options: DatabaseUseQueryOptions, +) { + return useDatabaseSubscriptionQuery(query, onChildChanged, options); +} diff --git a/packages/react/src/database/useOnChildMovedQuery.test.tsx b/packages/react/src/database/useOnChildMovedQuery.test.tsx new file mode 100644 index 00000000..3cd08131 --- /dev/null +++ b/packages/react/src/database/useOnChildMovedQuery.test.tsx @@ -0,0 +1,44 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { + child, + orderByPriority, + query, + ref, + set, + setPriority, +} from "firebase/database"; +import { beforeEach, describe, expect, test } from "vitest"; +import { database, wipeDatabase } from "~/testing-utils"; +import { queryClient, wrapper } from "../../utils"; +import { useOnChildMovedQuery } from "./useOnChildMovedQuery"; + +describe("useOnChildMovedQuery", () => { + beforeEach(async () => { + queryClient.clear(); + await wipeDatabase(); + }); + + test("receives child_moved snapshots", async () => { + const listRef = ref(database, "tests/useOnChildMovedQuery"); + const listQuery = query(listRef, orderByPriority()); + const childA = child(listRef, "a"); + const childB = child(listRef, "b"); + + const { result } = renderHook( + () => + useOnChildMovedQuery(listQuery, { + queryKey: ["database", "onChildMoved", "tests/useOnChildMovedQuery"], + }), + { wrapper }, + ); + + await set(childA, { name: "alpha" }); + await setPriority(childA, 1); + await set(childB, { name: "beta" }); + await setPriority(childB, 2); + await setPriority(childA, 3); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.key).toBe("a"); + }); +}); diff --git a/packages/react/src/database/useOnChildMovedQuery.ts b/packages/react/src/database/useOnChildMovedQuery.ts new file mode 100644 index 00000000..f7196d06 --- /dev/null +++ b/packages/react/src/database/useOnChildMovedQuery.ts @@ -0,0 +1,31 @@ +import type { FirebaseError } from "firebase/app"; +import { type DataSnapshot, onChildMoved, type Query } from "firebase/database"; +import type { DatabaseUseQueryOptions } from "./types"; +import { useDatabaseSubscriptionQuery } from "./useDatabaseSubscriptionQuery"; + +/** + * Hook to subscribe to `child_moved` events on a Realtime Database query. + * + * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.md#onchildmoved | onChildMoved}. + * + * @param query - A `Query` ordered with `orderByPriority()` (required for move events). + * @param options - TanStack Query options; `queryKey` is required. + * @returns TanStack Query result with the latest moved child `DataSnapshot`. + * + * @remarks + * `child_moved` is only raised for queries that use {@link https://firebase.google.com/docs/reference/js/database.md#orderbypriority | orderByPriority}. + * + * @example + * ```tsx + * const listQuery = query(ref(database, "items"), orderByPriority()); + * const { data: snapshot } = useOnChildMovedQuery(listQuery, { + * queryKey: ["database", "items", "moved"], + * }); + * ``` + */ +export function useOnChildMovedQuery( + query: Query, + options: DatabaseUseQueryOptions, +) { + return useDatabaseSubscriptionQuery(query, onChildMoved, options); +} diff --git a/packages/react/src/database/useOnChildRemovedQuery.test.tsx b/packages/react/src/database/useOnChildRemovedQuery.test.tsx new file mode 100644 index 00000000..6f3182f8 --- /dev/null +++ b/packages/react/src/database/useOnChildRemovedQuery.test.tsx @@ -0,0 +1,36 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { child, ref, remove, set } from "firebase/database"; +import { beforeEach, describe, expect, test } from "vitest"; +import { database, wipeDatabase } from "~/testing-utils"; +import { queryClient, wrapper } from "../../utils"; +import { useOnChildRemovedQuery } from "./useOnChildRemovedQuery"; + +describe("useOnChildRemovedQuery", () => { + beforeEach(async () => { + queryClient.clear(); + await wipeDatabase(); + }); + + test("receives child_removed snapshots", async () => { + const listRef = ref(database, "tests/useOnChildRemovedQuery"); + const childRef = child(listRef, "a"); + + const { result } = renderHook( + () => + useOnChildRemovedQuery(listRef, { + queryKey: [ + "database", + "onChildRemoved", + "tests/useOnChildRemovedQuery", + ], + }), + { wrapper }, + ); + + await set(childRef, { name: "alpha" }); + await remove(childRef); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.key).toBe("a"); + }); +}); diff --git a/packages/react/src/database/useOnChildRemovedQuery.ts b/packages/react/src/database/useOnChildRemovedQuery.ts new file mode 100644 index 00000000..669c34ff --- /dev/null +++ b/packages/react/src/database/useOnChildRemovedQuery.ts @@ -0,0 +1,27 @@ +import type { FirebaseError } from "firebase/app"; +import { + type DataSnapshot, + onChildRemoved, + type Query, +} from "firebase/database"; +import type { DatabaseUseQueryOptions } from "./types"; +import { useDatabaseSubscriptionQuery } from "./useDatabaseSubscriptionQuery"; + +/** + * Hook to subscribe to `child_removed` events on a Realtime Database query. + * + * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.md#onchildremoved | onChildRemoved}. + * + * @param query - A `DatabaseReference` or `Query`. + * @param options - TanStack Query options; `queryKey` is required. + * @returns TanStack Query result with the removed child `DataSnapshot` (may have null value). + * + * @remarks + * Fires when a child is deleted from the query result. + */ +export function useOnChildRemovedQuery( + query: Query, + options: DatabaseUseQueryOptions, +) { + return useDatabaseSubscriptionQuery(query, onChildRemoved, options); +} diff --git a/packages/react/src/database/useOnDisconnectCancelMutation.test.tsx b/packages/react/src/database/useOnDisconnectCancelMutation.test.tsx new file mode 100644 index 00000000..5ddfd1e6 --- /dev/null +++ b/packages/react/src/database/useOnDisconnectCancelMutation.test.tsx @@ -0,0 +1,29 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { onDisconnect, ref, set } from "firebase/database"; +import { beforeEach, describe, expect, test } from "vitest"; +import { database, wipeDatabase } from "~/testing-utils"; +import { queryClient, wrapper } from "../../utils"; +import { useOnDisconnectCancelMutation } from "./useOnDisconnectCancelMutation"; + +describe("useOnDisconnectCancelMutation", () => { + beforeEach(async () => { + queryClient.clear(); + await wipeDatabase(); + }); + + test("cancels a pending onDisconnect operation", async () => { + const dbRef = ref(database, "tests/useOnDisconnectCancelMutation"); + await set(dbRef, { status: "online" }); + await onDisconnect(dbRef).set({ status: "offline" }); + + const { result } = renderHook(() => useOnDisconnectCancelMutation(dbRef), { + wrapper, + }); + + await act(async () => { + result.current.mutate(); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + }); +}); diff --git a/packages/react/src/database/useOnDisconnectCancelMutation.ts b/packages/react/src/database/useOnDisconnectCancelMutation.ts new file mode 100644 index 00000000..11c988d1 --- /dev/null +++ b/packages/react/src/database/useOnDisconnectCancelMutation.ts @@ -0,0 +1,23 @@ +import { useMutation } from "@tanstack/react-query"; +import type { FirebaseError } from "firebase/app"; +import { type DatabaseReference, onDisconnect } from "firebase/database"; +import type { DatabaseMutationOptions } from "./types"; + +/** + * Hook to cancel all pending on-disconnect operations at a location. + * + * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.ondisconnect.md#cancel | OnDisconnect.cancel}. + * + * @param ref - The target `DatabaseReference`. + * @param options - TanStack Mutation options. + * @returns TanStack Mutation result. Call `mutate()` to cancel queued disconnect writes. + */ +export function useOnDisconnectCancelMutation( + ref: DatabaseReference, + options?: DatabaseMutationOptions, +) { + return useMutation({ + ...options, + mutationFn: () => onDisconnect(ref).cancel(), + }); +} diff --git a/packages/react/src/database/useOnDisconnectRemoveMutation.test.tsx b/packages/react/src/database/useOnDisconnectRemoveMutation.test.tsx new file mode 100644 index 00000000..4e6a05a9 --- /dev/null +++ b/packages/react/src/database/useOnDisconnectRemoveMutation.test.tsx @@ -0,0 +1,28 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { ref, set } from "firebase/database"; +import { beforeEach, describe, expect, test } from "vitest"; +import { database, wipeDatabase } from "~/testing-utils"; +import { queryClient, wrapper } from "../../utils"; +import { useOnDisconnectRemoveMutation } from "./useOnDisconnectRemoveMutation"; + +describe("useOnDisconnectRemoveMutation", () => { + beforeEach(async () => { + queryClient.clear(); + await wipeDatabase(); + }); + + test("registers an onDisconnect remove operation", async () => { + const dbRef = ref(database, "tests/useOnDisconnectRemoveMutation"); + await set(dbRef, { status: "online" }); + + const { result } = renderHook(() => useOnDisconnectRemoveMutation(dbRef), { + wrapper, + }); + + await act(async () => { + result.current.mutate(); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + }); +}); diff --git a/packages/react/src/database/useOnDisconnectRemoveMutation.ts b/packages/react/src/database/useOnDisconnectRemoveMutation.ts new file mode 100644 index 00000000..62103026 --- /dev/null +++ b/packages/react/src/database/useOnDisconnectRemoveMutation.ts @@ -0,0 +1,23 @@ +import { useMutation } from "@tanstack/react-query"; +import type { FirebaseError } from "firebase/app"; +import { type DatabaseReference, onDisconnect } from "firebase/database"; +import type { DatabaseMutationOptions } from "./types"; + +/** + * Hook to queue removal of data at a location when the client disconnects. + * + * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.ondisconnect.md#remove | OnDisconnect.remove}. + * + * @param ref - The target `DatabaseReference`. + * @param options - TanStack Mutation options. + * @returns TanStack Mutation result. Call `mutate()` to register the operation. + */ +export function useOnDisconnectRemoveMutation( + ref: DatabaseReference, + options?: DatabaseMutationOptions, +) { + return useMutation({ + ...options, + mutationFn: () => onDisconnect(ref).remove(), + }); +} diff --git a/packages/react/src/database/useOnDisconnectSetMutation.test.tsx b/packages/react/src/database/useOnDisconnectSetMutation.test.tsx new file mode 100644 index 00000000..8cb332f8 --- /dev/null +++ b/packages/react/src/database/useOnDisconnectSetMutation.test.tsx @@ -0,0 +1,28 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { ref, set } from "firebase/database"; +import { beforeEach, describe, expect, test } from "vitest"; +import { database, wipeDatabase } from "~/testing-utils"; +import { queryClient, wrapper } from "../../utils"; +import { useOnDisconnectSetMutation } from "./useOnDisconnectSetMutation"; + +describe("useOnDisconnectSetMutation", () => { + beforeEach(async () => { + queryClient.clear(); + await wipeDatabase(); + }); + + test("registers an onDisconnect set operation", async () => { + const dbRef = ref(database, "tests/useOnDisconnectSetMutation"); + await set(dbRef, { status: "online" }); + + const { result } = renderHook(() => useOnDisconnectSetMutation(dbRef), { + wrapper, + }); + + await act(async () => { + result.current.mutate({ status: "offline" }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + }); +}); diff --git a/packages/react/src/database/useOnDisconnectSetMutation.ts b/packages/react/src/database/useOnDisconnectSetMutation.ts new file mode 100644 index 00000000..c7093ab9 --- /dev/null +++ b/packages/react/src/database/useOnDisconnectSetMutation.ts @@ -0,0 +1,23 @@ +import { useMutation } from "@tanstack/react-query"; +import type { FirebaseError } from "firebase/app"; +import { type DatabaseReference, onDisconnect } from "firebase/database"; +import type { DatabaseMutationOptions } from "./types"; + +/** + * Hook to queue a `set` at a location when the client disconnects. + * + * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.ondisconnect.md#set | OnDisconnect.set}. + * + * @param ref - The target `DatabaseReference`. + * @param options - TanStack Mutation options. + * @returns TanStack Mutation result. Call `mutate(value)` to register the disconnect write. + */ +export function useOnDisconnectSetMutation( + ref: DatabaseReference, + options?: DatabaseMutationOptions, +) { + return useMutation({ + ...options, + mutationFn: (value) => onDisconnect(ref).set(value), + }); +} diff --git a/packages/react/src/database/useOnDisconnectSetWithPriorityMutation.test.tsx b/packages/react/src/database/useOnDisconnectSetWithPriorityMutation.test.tsx new file mode 100644 index 00000000..d448dac3 --- /dev/null +++ b/packages/react/src/database/useOnDisconnectSetWithPriorityMutation.test.tsx @@ -0,0 +1,29 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { ref, set } from "firebase/database"; +import { beforeEach, describe, expect, test } from "vitest"; +import { database, wipeDatabase } from "~/testing-utils"; +import { queryClient, wrapper } from "../../utils"; +import { useOnDisconnectSetWithPriorityMutation } from "./useOnDisconnectSetWithPriorityMutation"; + +describe("useOnDisconnectSetWithPriorityMutation", () => { + beforeEach(async () => { + queryClient.clear(); + await wipeDatabase(); + }); + + test("registers an onDisconnect setWithPriority operation", async () => { + const dbRef = ref(database, "tests/useOnDisconnectSetWithPriorityMutation"); + await set(dbRef, { status: "online" }); + + const { result } = renderHook( + () => useOnDisconnectSetWithPriorityMutation(dbRef), + { wrapper }, + ); + + await act(async () => { + result.current.mutate({ value: { status: "offline" }, priority: 1 }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + }); +}); diff --git a/packages/react/src/database/useOnDisconnectSetWithPriorityMutation.ts b/packages/react/src/database/useOnDisconnectSetWithPriorityMutation.ts new file mode 100644 index 00000000..ec20d73d --- /dev/null +++ b/packages/react/src/database/useOnDisconnectSetWithPriorityMutation.ts @@ -0,0 +1,35 @@ +import { useMutation } from "@tanstack/react-query"; +import type { FirebaseError } from "firebase/app"; +import { type DatabaseReference, onDisconnect } from "firebase/database"; +import type { DatabaseMutationOptions } from "./types"; + +type OnDisconnectSetWithPriorityVariables = { + value: unknown; + priority: string | number | null; +}; + +/** + * Hook to queue a `setWithPriority` at a location when the client disconnects. + * + * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.ondisconnect.md#setwithpriority | OnDisconnect.setWithPriority}. + * + * @param ref - The target `DatabaseReference`. + * @param options - TanStack Mutation options. + * @returns TanStack Mutation result. Call `mutate({ value, priority })`. + */ +export function useOnDisconnectSetWithPriorityMutation( + ref: DatabaseReference, + options?: DatabaseMutationOptions< + void, + FirebaseError, + OnDisconnectSetWithPriorityVariables + >, +) { + return useMutation( + { + ...options, + mutationFn: ({ value, priority }) => + onDisconnect(ref).setWithPriority(value, priority), + }, + ); +} diff --git a/packages/react/src/database/useOnDisconnectUpdateMutation.test.tsx b/packages/react/src/database/useOnDisconnectUpdateMutation.test.tsx new file mode 100644 index 00000000..37736637 --- /dev/null +++ b/packages/react/src/database/useOnDisconnectUpdateMutation.test.tsx @@ -0,0 +1,28 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { ref, set } from "firebase/database"; +import { beforeEach, describe, expect, test } from "vitest"; +import { database, wipeDatabase } from "~/testing-utils"; +import { queryClient, wrapper } from "../../utils"; +import { useOnDisconnectUpdateMutation } from "./useOnDisconnectUpdateMutation"; + +describe("useOnDisconnectUpdateMutation", () => { + beforeEach(async () => { + queryClient.clear(); + await wipeDatabase(); + }); + + test("registers an onDisconnect update operation", async () => { + const dbRef = ref(database, "tests/useOnDisconnectUpdateMutation"); + await set(dbRef, { status: "online", retries: 0 }); + + const { result } = renderHook(() => useOnDisconnectUpdateMutation(dbRef), { + wrapper, + }); + + await act(async () => { + result.current.mutate({ status: "offline" }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + }); +}); diff --git a/packages/react/src/database/useOnDisconnectUpdateMutation.ts b/packages/react/src/database/useOnDisconnectUpdateMutation.ts new file mode 100644 index 00000000..969706c9 --- /dev/null +++ b/packages/react/src/database/useOnDisconnectUpdateMutation.ts @@ -0,0 +1,27 @@ +import { useMutation } from "@tanstack/react-query"; +import type { FirebaseError } from "firebase/app"; +import { type DatabaseReference, onDisconnect } from "firebase/database"; +import type { DatabaseMutationOptions } from "./types"; + +/** + * Hook to queue a multi-path `update` at a location when the client disconnects. + * + * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.ondisconnect.md#update | OnDisconnect.update}. + * + * @param ref - The parent `DatabaseReference`. + * @param options - TanStack Mutation options. + * @returns TanStack Mutation result. Call `mutate(values)` with a partial update object. + */ +export function useOnDisconnectUpdateMutation( + ref: DatabaseReference, + options?: DatabaseMutationOptions< + void, + FirebaseError, + Record + >, +) { + return useMutation>({ + ...options, + mutationFn: (values) => onDisconnect(ref).update(values), + }); +} diff --git a/packages/react/src/database/useOnValueQuery.test.tsx b/packages/react/src/database/useOnValueQuery.test.tsx new file mode 100644 index 00000000..5e15ebb1 --- /dev/null +++ b/packages/react/src/database/useOnValueQuery.test.tsx @@ -0,0 +1,65 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { ref, set } from "firebase/database"; +import { beforeEach, describe, expect, test } from "vitest"; +import { database, wipeDatabase } from "~/testing-utils"; +import { queryClient, wrapper } from "../../utils"; +import { useOnValueQuery } from "./useOnValueQuery"; + +describe("useOnValueQuery", () => { + beforeEach(async () => { + queryClient.clear(); + await wipeDatabase(); + }); + + test("subscribes and returns the initial snapshot", async () => { + const dbRef = ref(database, "tests/useOnValueQuery"); + await set(dbRef, { count: 1 }); + + const { result } = renderHook( + () => + useOnValueQuery(dbRef, { + queryKey: ["database", "onValue", "tests/useOnValueQuery"], + }), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.val()).toEqual({ count: 1 }); + }); + + test("updates when the underlying value changes", async () => { + const dbRef = ref(database, "tests/useOnValueQuery-live"); + await set(dbRef, { count: 1 }); + + const { result } = renderHook( + () => + useOnValueQuery(dbRef, { + queryKey: ["database", "onValue", "tests/useOnValueQuery-live"], + }), + { wrapper }, + ); + + await waitFor(() => expect(result.current.data?.val()?.count).toBe(1)); + + await set(dbRef, { count: 2 }); + + await waitFor(() => expect(result.current.data?.val()?.count).toBe(2)); + }); + + test("respects enabled: false", async () => { + const dbRef = ref(database, "tests/useOnValueQuery-disabled"); + await set(dbRef, { count: 1 }); + + const { result } = renderHook( + () => + useOnValueQuery(dbRef, { + queryKey: ["database", "onValue", "tests/useOnValueQuery-disabled"], + enabled: false, + }), + { wrapper }, + ); + + expect(result.current.status).toBe("pending"); + expect(result.current.data).toBeUndefined(); + }); +}); diff --git a/packages/react/src/database/useOnValueQuery.ts b/packages/react/src/database/useOnValueQuery.ts new file mode 100644 index 00000000..ed179f0b --- /dev/null +++ b/packages/react/src/database/useOnValueQuery.ts @@ -0,0 +1,34 @@ +import type { FirebaseError } from "firebase/app"; +import { type DataSnapshot, onValue, type Query } from "firebase/database"; +import type { DatabaseUseQueryOptions } from "./types"; +import { useDatabaseSubscriptionQuery } from "./useDatabaseSubscriptionQuery"; + +/** + * Hook to subscribe to value events at a Realtime Database location. + * + * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.md#onvalue | onValue}. + * The first snapshot resolves the query; later snapshots update the cache via `setQueryData`. + * + * @param query - A `DatabaseReference` or `Query`. + * @param options - TanStack Query options; `queryKey` is required. Use `database` for `ListenOptions`. + * @returns TanStack Query result with the latest `DataSnapshot`. + * + * @remarks + * Components sharing the same `queryKey` share one TanStack Query cache entry. Use a stable, + * unique `queryKey` per path to avoid duplicate Firebase listeners. For a one-time read, prefer + * {@link useGetQuery}. + * + * @example + * ```tsx + * const userRef = ref(database, `users/${uid}`); + * const { data: snapshot } = useOnValueQuery(userRef, { + * queryKey: ["database", "onValue", "users", uid], + * }); + * ``` + */ +export function useOnValueQuery( + query: Query, + options: DatabaseUseQueryOptions, +) { + return useDatabaseSubscriptionQuery(query, onValue, options); +} diff --git a/packages/react/src/database/usePushMutation.test.tsx b/packages/react/src/database/usePushMutation.test.tsx new file mode 100644 index 00000000..d304ea0e --- /dev/null +++ b/packages/react/src/database/usePushMutation.test.tsx @@ -0,0 +1,31 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { get, ref } from "firebase/database"; +import { beforeEach, describe, expect, test } from "vitest"; +import { database, wipeDatabase } from "~/testing-utils"; +import { queryClient, wrapper } from "../../utils"; +import { usePushMutation } from "./usePushMutation"; + +describe("usePushMutation", () => { + beforeEach(async () => { + queryClient.clear(); + await wipeDatabase(); + }); + + test("pushes a new child with a value", async () => { + const listRef = ref(database, "tests/usePushMutation"); + + const { result } = renderHook(() => usePushMutation(listRef), { wrapper }); + + await act(async () => { + result.current.mutate({ name: "new-item" }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const pushedRef = result.current.data; + expect(pushedRef?.key).toBeTruthy(); + + const snapshot = await get(pushedRef!); + expect(snapshot.val()).toEqual({ name: "new-item" }); + }); +}); diff --git a/packages/react/src/database/usePushMutation.ts b/packages/react/src/database/usePushMutation.ts new file mode 100644 index 00000000..8b8d5441 --- /dev/null +++ b/packages/react/src/database/usePushMutation.ts @@ -0,0 +1,35 @@ +import { useMutation } from "@tanstack/react-query"; +import type { FirebaseError } from "firebase/app"; +import { type DatabaseReference, push } from "firebase/database"; +import type { DatabaseMutationOptions } from "./types"; + +/** + * Hook to append a child with an auto-generated key to a list. + * + * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.md#push | push}. + * + * @param ref - The list `DatabaseReference`. + * @param options - TanStack Mutation options. + * @returns TanStack Mutation result. `data` after success is the new child `DatabaseReference`. + * Call `mutate(value)` or `mutate(undefined)` to push without a value. + * + * @example + * ```tsx + * const messagesRef = ref(database, "rooms/room-1/messages"); + * const { mutate, data: newRef } = usePushMutation(messagesRef); + * mutate({ text: "Hello", sentAt: Date.now() }); + * ``` + */ +export function usePushMutation( + ref: DatabaseReference, + options?: DatabaseMutationOptions< + DatabaseReference, + FirebaseError, + unknown | undefined + >, +) { + return useMutation({ + ...options, + mutationFn: (value) => Promise.resolve(push(ref, value)), + }); +} diff --git a/packages/react/src/database/useRemoveMutation.test.tsx b/packages/react/src/database/useRemoveMutation.test.tsx new file mode 100644 index 00000000..bfc31635 --- /dev/null +++ b/packages/react/src/database/useRemoveMutation.test.tsx @@ -0,0 +1,29 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { get, ref, set } from "firebase/database"; +import { beforeEach, describe, expect, test } from "vitest"; +import { database, wipeDatabase } from "~/testing-utils"; +import { queryClient, wrapper } from "../../utils"; +import { useRemoveMutation } from "./useRemoveMutation"; + +describe("useRemoveMutation", () => { + beforeEach(async () => { + queryClient.clear(); + await wipeDatabase(); + }); + + test("removes data at a reference", async () => { + const dbRef = ref(database, "tests/useRemoveMutation"); + await set(dbRef, { hello: "world" }); + + const { result } = renderHook(() => useRemoveMutation(dbRef), { wrapper }); + + await act(async () => { + result.current.mutate(); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const snapshot = await get(dbRef); + expect(snapshot.exists()).toBe(false); + }); +}); diff --git a/packages/react/src/database/useRemoveMutation.ts b/packages/react/src/database/useRemoveMutation.ts new file mode 100644 index 00000000..96c0fc5b --- /dev/null +++ b/packages/react/src/database/useRemoveMutation.ts @@ -0,0 +1,23 @@ +import { useMutation } from "@tanstack/react-query"; +import type { FirebaseError } from "firebase/app"; +import { type DatabaseReference, remove } from "firebase/database"; +import type { DatabaseMutationOptions } from "./types"; + +/** + * Hook to delete data at a Realtime Database location. + * + * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.md#remove | remove}. + * + * @param ref - The target `DatabaseReference`. + * @param options - TanStack Mutation options. + * @returns TanStack Mutation result. Call `mutate()` with no arguments. + */ +export function useRemoveMutation( + ref: DatabaseReference, + options?: DatabaseMutationOptions, +) { + return useMutation({ + ...options, + mutationFn: () => remove(ref), + }); +} diff --git a/packages/react/src/database/useRunTransactionMutation.test.tsx b/packages/react/src/database/useRunTransactionMutation.test.tsx new file mode 100644 index 00000000..8cc32d5e --- /dev/null +++ b/packages/react/src/database/useRunTransactionMutation.test.tsx @@ -0,0 +1,37 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { get, ref, set } from "firebase/database"; +import { beforeEach, describe, expect, test } from "vitest"; +import { database, wipeDatabase } from "~/testing-utils"; +import { queryClient, wrapper } from "../../utils"; +import { useRunTransactionMutation } from "./useRunTransactionMutation"; + +describe("useRunTransactionMutation", () => { + beforeEach(async () => { + queryClient.clear(); + await wipeDatabase(); + }); + + test("runs a transaction and updates data", async () => { + const dbRef = ref(database, "tests/useRunTransactionMutation"); + await set(dbRef, { count: 1 }); + + const { result } = renderHook( + () => + useRunTransactionMutation(dbRef, (current) => { + const value = (current as { count?: number } | null)?.count ?? 0; + return { count: value + 1 }; + }), + { wrapper }, + ); + + await act(async () => { + result.current.mutate(); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const snapshot = await get(dbRef); + expect(snapshot.val()).toEqual({ count: 2 }); + expect(result.current.data?.committed).toBe(true); + }); +}); diff --git a/packages/react/src/database/useRunTransactionMutation.ts b/packages/react/src/database/useRunTransactionMutation.ts new file mode 100644 index 00000000..5f071bda --- /dev/null +++ b/packages/react/src/database/useRunTransactionMutation.ts @@ -0,0 +1,53 @@ +import { useMutation } from "@tanstack/react-query"; +import type { FirebaseError } from "firebase/app"; +import { + type DatabaseReference, + runTransaction, + type TransactionOptions, + type TransactionResult, +} from "firebase/database"; +import type { DatabaseMutationOptions } from "./types"; + +type TransactionUpdate = (currentData: unknown) => unknown; + +type RunTransactionMutationOptions = DatabaseMutationOptions< + TransactionResult, + FirebaseError, + void +> & { + database?: TransactionOptions; +}; + +/** + * Hook to run an atomic read-modify-write transaction on a Realtime Database location. + * + * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.md#runtransaction | runTransaction}. + * + * @param ref - The target `DatabaseReference`. + * @param transactionUpdate - Function that receives current data and returns the new value (or `undefined` to abort). + * @param options - TanStack Mutation options. Pass `database` for {@link https://firebase.google.com/docs/reference/js/database.transactionoptions | TransactionOptions}. + * @returns TanStack Mutation result with `TransactionResult` on success. Call `mutate()` to run. + * + * @example + * ```tsx + * const counterRef = ref(database, "counters/views"); + * const { mutate } = useRunTransactionMutation(counterRef, (current) => { + * const count = (current as number | null) ?? 0; + * return count + 1; + * }); + * mutate(); + * ``` + */ +export function useRunTransactionMutation( + ref: DatabaseReference, + transactionUpdate: TransactionUpdate, + options?: RunTransactionMutationOptions, +) { + const { database: transactionOptions, ...mutationOptions } = options ?? {}; + + return useMutation({ + ...mutationOptions, + mutationFn: () => + runTransaction(ref, transactionUpdate, transactionOptions), + }); +} diff --git a/packages/react/src/database/useSetMutation.test.tsx b/packages/react/src/database/useSetMutation.test.tsx new file mode 100644 index 00000000..4c1391f7 --- /dev/null +++ b/packages/react/src/database/useSetMutation.test.tsx @@ -0,0 +1,28 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { get, ref } from "firebase/database"; +import { beforeEach, describe, expect, test } from "vitest"; +import { database, wipeDatabase } from "~/testing-utils"; +import { queryClient, wrapper } from "../../utils"; +import { useSetMutation } from "./useSetMutation"; + +describe("useSetMutation", () => { + beforeEach(async () => { + queryClient.clear(); + await wipeDatabase(); + }); + + test("writes a value to the database", async () => { + const dbRef = ref(database, "tests/useSetMutation"); + + const { result } = renderHook(() => useSetMutation(dbRef), { wrapper }); + + await act(async () => { + result.current.mutate({ hello: "world" }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const snapshot = await get(dbRef); + expect(snapshot.val()).toEqual({ hello: "world" }); + }); +}); diff --git a/packages/react/src/database/useSetMutation.ts b/packages/react/src/database/useSetMutation.ts new file mode 100644 index 00000000..ef3f4a2e --- /dev/null +++ b/packages/react/src/database/useSetMutation.ts @@ -0,0 +1,30 @@ +import { useMutation } from "@tanstack/react-query"; +import type { FirebaseError } from "firebase/app"; +import { type DatabaseReference, set } from "firebase/database"; +import type { DatabaseMutationOptions } from "./types"; + +/** + * Hook to write data to a Realtime Database location, replacing any existing data. + * + * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.md#set | set}. + * + * @param ref - The target `DatabaseReference`. + * @param options - TanStack Mutation options. + * @returns TanStack Mutation result. Call `mutate(value)` to write. + * + * @example + * ```tsx + * const userRef = ref(database, `users/${uid}`); + * const { mutate } = useSetMutation(userRef); + * mutate({ name: "Ada", score: 1 }); + * ``` + */ +export function useSetMutation( + ref: DatabaseReference, + options?: DatabaseMutationOptions, +) { + return useMutation({ + ...options, + mutationFn: (value) => set(ref, value), + }); +} diff --git a/packages/react/src/database/useSetPriorityMutation.test.tsx b/packages/react/src/database/useSetPriorityMutation.test.tsx new file mode 100644 index 00000000..6025594a --- /dev/null +++ b/packages/react/src/database/useSetPriorityMutation.test.tsx @@ -0,0 +1,31 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { get, ref, set } from "firebase/database"; +import { beforeEach, describe, expect, test } from "vitest"; +import { database, wipeDatabase } from "~/testing-utils"; +import { queryClient, wrapper } from "../../utils"; +import { useSetPriorityMutation } from "./useSetPriorityMutation"; + +describe("useSetPriorityMutation", () => { + beforeEach(async () => { + queryClient.clear(); + await wipeDatabase(); + }); + + test("sets priority on a reference", async () => { + const dbRef = ref(database, "tests/useSetPriorityMutation"); + await set(dbRef, { name: "item" }); + + const { result } = renderHook(() => useSetPriorityMutation(dbRef), { + wrapper, + }); + + await act(async () => { + result.current.mutate(10); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const snapshot = await get(dbRef); + expect(snapshot.priority).toBe(10); + }); +}); diff --git a/packages/react/src/database/useSetPriorityMutation.ts b/packages/react/src/database/useSetPriorityMutation.ts new file mode 100644 index 00000000..d1b51f15 --- /dev/null +++ b/packages/react/src/database/useSetPriorityMutation.ts @@ -0,0 +1,27 @@ +import { useMutation } from "@tanstack/react-query"; +import type { FirebaseError } from "firebase/app"; +import { type DatabaseReference, setPriority } from "firebase/database"; +import type { DatabaseMutationOptions } from "./types"; + +/** + * Hook to set the priority of a Realtime Database location. + * + * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.md#setpriority | setPriority}. + * + * @param ref - The target `DatabaseReference`. + * @param options - TanStack Mutation options. + * @returns TanStack Mutation result. Call `mutate(priority)` with a string, number, or `null`. + */ +export function useSetPriorityMutation( + ref: DatabaseReference, + options?: DatabaseMutationOptions< + void, + FirebaseError, + string | number | null + >, +) { + return useMutation({ + ...options, + mutationFn: (priority) => setPriority(ref, priority), + }); +} diff --git a/packages/react/src/database/useSetWithPriorityMutation.test.tsx b/packages/react/src/database/useSetWithPriorityMutation.test.tsx new file mode 100644 index 00000000..313016a2 --- /dev/null +++ b/packages/react/src/database/useSetWithPriorityMutation.test.tsx @@ -0,0 +1,31 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { get, ref } from "firebase/database"; +import { beforeEach, describe, expect, test } from "vitest"; +import { database, wipeDatabase } from "~/testing-utils"; +import { queryClient, wrapper } from "../../utils"; +import { useSetWithPriorityMutation } from "./useSetWithPriorityMutation"; + +describe("useSetWithPriorityMutation", () => { + beforeEach(async () => { + queryClient.clear(); + await wipeDatabase(); + }); + + test("writes a value with priority", async () => { + const dbRef = ref(database, "tests/useSetWithPriorityMutation"); + + const { result } = renderHook(() => useSetWithPriorityMutation(dbRef), { + wrapper, + }); + + await act(async () => { + result.current.mutate({ value: { name: "item" }, priority: 5 }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const snapshot = await get(dbRef); + expect(snapshot.val()).toEqual({ name: "item" }); + expect(snapshot.priority).toBe(5); + }); +}); diff --git a/packages/react/src/database/useSetWithPriorityMutation.ts b/packages/react/src/database/useSetWithPriorityMutation.ts new file mode 100644 index 00000000..7c8a5a7d --- /dev/null +++ b/packages/react/src/database/useSetWithPriorityMutation.ts @@ -0,0 +1,38 @@ +import { useMutation } from "@tanstack/react-query"; +import type { FirebaseError } from "firebase/app"; +import { type DatabaseReference, setWithPriority } from "firebase/database"; +import type { DatabaseMutationOptions } from "./types"; + +type SetWithPriorityVariables = { + value: unknown; + priority: string | number | null; +}; + +/** + * Hook to write data and priority to a Realtime Database location in one operation. + * + * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.md#setwithpriority | setWithPriority}. + * + * @param ref - The target `DatabaseReference`. + * @param options - TanStack Mutation options. + * @returns TanStack Mutation result. Call `mutate({ value, priority })`. + * + * @example + * ```tsx + * const { mutate } = useSetWithPriorityMutation(itemRef); + * mutate({ value: { name: "Ada" }, priority: 1 }); + * ``` + */ +export function useSetWithPriorityMutation( + ref: DatabaseReference, + options?: DatabaseMutationOptions< + void, + FirebaseError, + SetWithPriorityVariables + >, +) { + return useMutation({ + ...options, + mutationFn: ({ value, priority }) => setWithPriority(ref, value, priority), + }); +} diff --git a/packages/react/src/database/useUpdateMutation.test.tsx b/packages/react/src/database/useUpdateMutation.test.tsx new file mode 100644 index 00000000..9b411080 --- /dev/null +++ b/packages/react/src/database/useUpdateMutation.test.tsx @@ -0,0 +1,29 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { get, ref, set } from "firebase/database"; +import { beforeEach, describe, expect, test } from "vitest"; +import { database, wipeDatabase } from "~/testing-utils"; +import { queryClient, wrapper } from "../../utils"; +import { useUpdateMutation } from "./useUpdateMutation"; + +describe("useUpdateMutation", () => { + beforeEach(async () => { + queryClient.clear(); + await wipeDatabase(); + }); + + test("updates child paths on a reference", async () => { + const dbRef = ref(database, "tests/useUpdateMutation"); + await set(dbRef, { a: 1, b: 2 }); + + const { result } = renderHook(() => useUpdateMutation(dbRef), { wrapper }); + + await act(async () => { + result.current.mutate({ b: 3 }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const snapshot = await get(dbRef); + expect(snapshot.val()).toEqual({ a: 1, b: 3 }); + }); +}); diff --git a/packages/react/src/database/useUpdateMutation.ts b/packages/react/src/database/useUpdateMutation.ts new file mode 100644 index 00000000..0d4fbe78 --- /dev/null +++ b/packages/react/src/database/useUpdateMutation.ts @@ -0,0 +1,33 @@ +import { useMutation } from "@tanstack/react-query"; +import type { FirebaseError } from "firebase/app"; +import { type DatabaseReference, update } from "firebase/database"; +import type { DatabaseMutationOptions } from "./types"; + +/** + * Hook to perform a multi-path update without replacing data at `ref`. + * + * Wraps Firebase {@link https://firebase.google.com/docs/reference/js/database.md#update | update}. + * + * @param ref - The parent `DatabaseReference` (keys in `values` are relative child paths). + * @param options - TanStack Mutation options. + * @returns TanStack Mutation result. Call `mutate(values)` with a partial update object. + * + * @example + * ```tsx + * const { mutate } = useUpdateMutation(userRef); + * mutate({ "settings/theme": "dark", score: 10 }); + * ``` + */ +export function useUpdateMutation( + ref: DatabaseReference, + options?: DatabaseMutationOptions< + void, + FirebaseError, + Record + >, +) { + return useMutation>({ + ...options, + mutationFn: (values) => update(ref, values), + }); +} diff --git a/packages/react/tsup.config.ts b/packages/react/tsup.config.ts index da3647bf..6a55ddd7 100644 --- a/packages/react/tsup.config.ts +++ b/packages/react/tsup.config.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs/promises"; import { defineConfig } from "tsup"; -const supportedPackages = ["data-connect", "firestore", "auth"]; +const supportedPackages = ["data-connect", "firestore", "auth", "database"]; export default defineConfig({ entry: [`src/(${supportedPackages.join("|")})/index.ts`, "src/index.ts"], format: ["esm"], diff --git a/packages/react/vitest/utils.ts b/packages/react/vitest/utils.ts index cdf1d667..42b8b22e 100644 --- a/packages/react/vitest/utils.ts +++ b/packages/react/vitest/utils.ts @@ -4,6 +4,11 @@ import { connectDataConnectEmulator, getDataConnect, } from "firebase/data-connect"; +import { + connectDatabaseEmulator, + type Database, + getDatabase, +} from "firebase/database"; import { connectFirestoreEmulator, type Firestore, @@ -21,13 +26,16 @@ const firebaseTestingOptions = { let firebaseApp: FirebaseApp | undefined; let firestore: Firestore; let auth: Auth; +let database: Database; if (!firebaseApp) { firebaseApp = initializeApp(firebaseTestingOptions); firestore = getFirestore(firebaseApp); auth = getAuth(firebaseApp); + database = getDatabase(firebaseApp); connectFirestoreEmulator(firestore, "localhost", 8080); + connectDatabaseEmulator(database, "localhost", 9000); connectAuthEmulator(auth, "http://localhost:9099"); connectDataConnectEmulator( getDataConnect(connectorConfig), @@ -49,6 +57,19 @@ async function wipeFirestore() { } } +async function wipeDatabase() { + const response = await fetch( + "http://localhost:9000/.json?ns=test-project-default-rtdb", + { + method: "DELETE", + }, + ); + + if (!response.ok) { + throw new Error("Failed to wipe realtime database"); + } +} + async function wipeAuth() { const response = await fetch( "http://localhost:9099/emulator/v1/projects/test-project/accounts", @@ -102,4 +123,6 @@ export { wipeAuth, firebaseApp, expectFirebaseError, + database, + wipeDatabase, };