From 4f73145dc62bae384f9ad0c95735deb6b6736354 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Tue, 26 May 2026 14:55:13 +0100 Subject: [PATCH 1/6] feat(react): implement Realtime Database hooks with tests and exports Add query and mutation hooks for firebase/database, including subscription support via TanStack Query cache updates, emulator test utilities, and the ./database package export. --- packages/react/package.json | 4 + .../createSubscriptionQueryFn.test.ts | 89 +++++++++++++++++++ .../src/database/createSubscriptionQueryFn.ts | 57 ++++++++++++ packages/react/src/database/index.ts | 36 ++++++-- packages/react/src/database/types.ts | 29 ++++++ .../database/useDatabaseSubscriptionQuery.ts | 41 +++++++++ .../react/src/database/useGetQuery.test.tsx | 49 ++++++++++ packages/react/src/database/useGetQuery.ts | 34 +++++++ .../database/useGoOfflineMutation.test.tsx | 24 +++++ .../src/database/useGoOfflineMutation.ts | 25 ++++++ .../src/database/useGoOnlineMutation.test.tsx | 24 +++++ .../react/src/database/useGoOnlineMutation.ts | 25 ++++++ .../database/useOnChildAddedQuery.test.tsx | 30 +++++++ .../src/database/useOnChildAddedQuery.ts | 24 +++++ .../database/useOnChildChangedQuery.test.tsx | 36 ++++++++ .../src/database/useOnChildChangedQuery.ts | 23 +++++ .../database/useOnChildMovedQuery.test.tsx | 37 ++++++++ .../src/database/useOnChildMovedQuery.ts | 31 +++++++ .../database/useOnChildRemovedQuery.test.tsx | 36 ++++++++ .../src/database/useOnChildRemovedQuery.ts | 23 +++++ .../useOnDisconnectCancelMutation.test.tsx | 29 ++++++ .../database/useOnDisconnectCancelMutation.ts | 23 +++++ .../useOnDisconnectRemoveMutation.test.tsx | 28 ++++++ .../database/useOnDisconnectRemoveMutation.ts | 23 +++++ .../useOnDisconnectSetMutation.test.tsx | 28 ++++++ .../database/useOnDisconnectSetMutation.ts | 23 +++++ ...DisconnectSetWithPriorityMutation.test.tsx | 29 ++++++ .../useOnDisconnectSetWithPriorityMutation.ts | 35 ++++++++ .../useOnDisconnectUpdateMutation.test.tsx | 28 ++++++ .../database/useOnDisconnectUpdateMutation.ts | 27 ++++++ .../src/database/useOnValueQuery.test.tsx | 65 ++++++++++++++ .../react/src/database/useOnValueQuery.ts | 34 +++++++ .../src/database/usePushMutation.test.tsx | 31 +++++++ .../react/src/database/usePushMutation.ts | 35 ++++++++ .../src/database/useRemoveMutation.test.tsx | 29 ++++++ .../react/src/database/useRemoveMutation.ts | 23 +++++ .../useRunTransactionMutation.test.tsx | 37 ++++++++ .../src/database/useRunTransactionMutation.ts | 55 ++++++++++++ .../src/database/useSetMutation.test.tsx | 28 ++++++ packages/react/src/database/useSetMutation.ts | 30 +++++++ .../database/useSetPriorityMutation.test.tsx | 31 +++++++ .../src/database/useSetPriorityMutation.ts | 27 ++++++ .../useSetWithPriorityMutation.test.tsx | 31 +++++++ .../database/useSetWithPriorityMutation.ts | 38 ++++++++ .../src/database/useUpdateMutation.test.tsx | 29 ++++++ .../react/src/database/useUpdateMutation.ts | 33 +++++++ packages/react/tsup.config.ts | 2 +- packages/react/vitest/utils.ts | 23 +++++ 48 files changed, 1525 insertions(+), 6 deletions(-) create mode 100644 packages/react/src/database/createSubscriptionQueryFn.test.ts create mode 100644 packages/react/src/database/createSubscriptionQueryFn.ts create mode 100644 packages/react/src/database/types.ts create mode 100644 packages/react/src/database/useDatabaseSubscriptionQuery.ts create mode 100644 packages/react/src/database/useGetQuery.test.tsx create mode 100644 packages/react/src/database/useGetQuery.ts create mode 100644 packages/react/src/database/useGoOfflineMutation.test.tsx create mode 100644 packages/react/src/database/useGoOfflineMutation.ts create mode 100644 packages/react/src/database/useGoOnlineMutation.test.tsx create mode 100644 packages/react/src/database/useGoOnlineMutation.ts create mode 100644 packages/react/src/database/useOnChildAddedQuery.test.tsx create mode 100644 packages/react/src/database/useOnChildAddedQuery.ts create mode 100644 packages/react/src/database/useOnChildChangedQuery.test.tsx create mode 100644 packages/react/src/database/useOnChildChangedQuery.ts create mode 100644 packages/react/src/database/useOnChildMovedQuery.test.tsx create mode 100644 packages/react/src/database/useOnChildMovedQuery.ts create mode 100644 packages/react/src/database/useOnChildRemovedQuery.test.tsx create mode 100644 packages/react/src/database/useOnChildRemovedQuery.ts create mode 100644 packages/react/src/database/useOnDisconnectCancelMutation.test.tsx create mode 100644 packages/react/src/database/useOnDisconnectCancelMutation.ts create mode 100644 packages/react/src/database/useOnDisconnectRemoveMutation.test.tsx create mode 100644 packages/react/src/database/useOnDisconnectRemoveMutation.ts create mode 100644 packages/react/src/database/useOnDisconnectSetMutation.test.tsx create mode 100644 packages/react/src/database/useOnDisconnectSetMutation.ts create mode 100644 packages/react/src/database/useOnDisconnectSetWithPriorityMutation.test.tsx create mode 100644 packages/react/src/database/useOnDisconnectSetWithPriorityMutation.ts create mode 100644 packages/react/src/database/useOnDisconnectUpdateMutation.test.tsx create mode 100644 packages/react/src/database/useOnDisconnectUpdateMutation.ts create mode 100644 packages/react/src/database/useOnValueQuery.test.tsx create mode 100644 packages/react/src/database/useOnValueQuery.ts create mode 100644 packages/react/src/database/usePushMutation.test.tsx create mode 100644 packages/react/src/database/usePushMutation.ts create mode 100644 packages/react/src/database/useRemoveMutation.test.tsx create mode 100644 packages/react/src/database/useRemoveMutation.ts create mode 100644 packages/react/src/database/useRunTransactionMutation.test.tsx create mode 100644 packages/react/src/database/useRunTransactionMutation.ts create mode 100644 packages/react/src/database/useSetMutation.test.tsx create mode 100644 packages/react/src/database/useSetMutation.ts create mode 100644 packages/react/src/database/useSetPriorityMutation.test.tsx create mode 100644 packages/react/src/database/useSetPriorityMutation.ts create mode 100644 packages/react/src/database/useSetWithPriorityMutation.test.tsx create mode 100644 packages/react/src/database/useSetWithPriorityMutation.ts create mode 100644 packages/react/src/database/useUpdateMutation.test.tsx create mode 100644 packages/react/src/database/useUpdateMutation.ts 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..ffa9a35b --- /dev/null +++ b/packages/react/src/database/createSubscriptionQueryFn.ts @@ -0,0 +1,57 @@ +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..6567906c 100644 --- a/packages/react/src/database/index.ts +++ b/packages/react/src/database/index.ts @@ -1,5 +1,31 @@ -// 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 { useGetQuery } from "./useGetQuery"; +export { useOnValueQuery } from "./useOnValueQuery"; +export { useOnChildAddedQuery } from "./useOnChildAddedQuery"; +export { useOnChildChangedQuery } from "./useOnChildChangedQuery"; +export { useOnChildMovedQuery } from "./useOnChildMovedQuery"; +export { useOnChildRemovedQuery } from "./useOnChildRemovedQuery"; + +export { useSetMutation } from "./useSetMutation"; +export { useSetPriorityMutation } from "./useSetPriorityMutation"; +export { useSetWithPriorityMutation } from "./useSetWithPriorityMutation"; +export { useUpdateMutation } from "./useUpdateMutation"; +export { useRemoveMutation } from "./useRemoveMutation"; +export { useRunTransactionMutation } from "./useRunTransactionMutation"; +export { usePushMutation } from "./usePushMutation"; +export { useGoOfflineMutation } from "./useGoOfflineMutation"; +export { useGoOnlineMutation } from "./useGoOnlineMutation"; + +export { useOnDisconnectCancelMutation } from "./useOnDisconnectCancelMutation"; +export { useOnDisconnectRemoveMutation } from "./useOnDisconnectRemoveMutation"; +export { useOnDisconnectSetMutation } from "./useOnDisconnectSetMutation"; +export { useOnDisconnectSetWithPriorityMutation } from "./useOnDisconnectSetWithPriorityMutation"; +export { useOnDisconnectUpdateMutation } from "./useOnDisconnectUpdateMutation"; + +export type { DatabaseMutationOptions, DatabaseUseQueryOptions } from "./types"; diff --git a/packages/react/src/database/types.ts b/packages/react/src/database/types.ts new file mode 100644 index 00000000..7488ed4a --- /dev/null +++ b/packages/react/src/database/types.ts @@ -0,0 +1,29 @@ +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< + TData = unknown, + TError = Error, +> = Omit, "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..675cb62e --- /dev/null +++ b/packages/react/src/database/useDatabaseSubscriptionQuery.ts @@ -0,0 +1,41 @@ +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..6f9ac8fc --- /dev/null +++ b/packages/react/src/database/useOnChildChangedQuery.ts @@ -0,0 +1,23 @@ +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..e816e42c --- /dev/null +++ b/packages/react/src/database/useOnChildMovedQuery.test.tsx @@ -0,0 +1,37 @@ +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..958660a2 --- /dev/null +++ b/packages/react/src/database/useOnChildRemovedQuery.ts @@ -0,0 +1,23 @@ +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..2a9c319b --- /dev/null +++ b/packages/react/src/database/useRunTransactionMutation.ts @@ -0,0 +1,55 @@ +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, }; From 969e24db50165aaf329b7eb6452c5585a6d7fa5a Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Tue, 26 May 2026 15:03:28 +0100 Subject: [PATCH 2/6] fix(ci): apply Biome formatting and upgrade Java to 21 for Firebase v12 workflow Unblocks PR CI by fixing format check failures in database hooks and updating the emulator workflow to Java 21, which firebase-tools@latest now requires. --- .github/workflows/test-react-firebase-v12.yml | 2 +- .../src/database/createSubscriptionQueryFn.ts | 5 +++- packages/react/src/database/index.ts | 25 ++++++++----------- packages/react/src/database/types.ts | 13 ++++++---- .../database/useDatabaseSubscriptionQuery.ts | 7 +----- .../src/database/useOnChildChangedQuery.ts | 6 ++++- .../database/useOnChildMovedQuery.test.tsx | 9 ++++++- .../src/database/useOnChildRemovedQuery.ts | 6 ++++- .../src/database/useRunTransactionMutation.ts | 4 +-- 9 files changed, 44 insertions(+), 33 deletions(-) diff --git a/.github/workflows/test-react-firebase-v12.yml b/.github/workflows/test-react-firebase-v12.yml index 610e74dc..dc24f87d 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 diff --git a/packages/react/src/database/createSubscriptionQueryFn.ts b/packages/react/src/database/createSubscriptionQueryFn.ts index ffa9a35b..507b22ce 100644 --- a/packages/react/src/database/createSubscriptionQueryFn.ts +++ b/packages/react/src/database/createSubscriptionQueryFn.ts @@ -1,4 +1,7 @@ -import type { QueryFunction, QueryFunctionContext } from "@tanstack/react-query"; +import type { + QueryFunction, + QueryFunctionContext, +} from "@tanstack/react-query"; import type { Unsubscribe } from "firebase/database"; type ListenerHandlers = { diff --git a/packages/react/src/database/index.ts b/packages/react/src/database/index.ts index 6567906c..57018df0 100644 --- a/packages/react/src/database/index.ts +++ b/packages/react/src/database/index.ts @@ -5,27 +5,24 @@ // 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 { useOnValueQuery } from "./useOnValueQuery"; +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 { useSetMutation } from "./useSetMutation"; -export { useSetPriorityMutation } from "./useSetPriorityMutation"; -export { useSetWithPriorityMutation } from "./useSetWithPriorityMutation"; -export { useUpdateMutation } from "./useUpdateMutation"; -export { useRemoveMutation } from "./useRemoveMutation"; -export { useRunTransactionMutation } from "./useRunTransactionMutation"; -export { usePushMutation } from "./usePushMutation"; -export { useGoOfflineMutation } from "./useGoOfflineMutation"; -export { useGoOnlineMutation } from "./useGoOnlineMutation"; - export { useOnDisconnectCancelMutation } from "./useOnDisconnectCancelMutation"; export { useOnDisconnectRemoveMutation } from "./useOnDisconnectRemoveMutation"; export { useOnDisconnectSetMutation } from "./useOnDisconnectSetMutation"; export { useOnDisconnectSetWithPriorityMutation } from "./useOnDisconnectSetWithPriorityMutation"; export { useOnDisconnectUpdateMutation } from "./useOnDisconnectUpdateMutation"; - -export type { DatabaseMutationOptions, DatabaseUseQueryOptions } from "./types"; +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 index 7488ed4a..5071a5a6 100644 --- a/packages/react/src/database/types.ts +++ b/packages/react/src/database/types.ts @@ -1,4 +1,7 @@ -import type { UseMutationOptions, UseQueryOptions } from "@tanstack/react-query"; +import type { + UseMutationOptions, + UseQueryOptions, +} from "@tanstack/react-query"; import type { ListenOptions } from "firebase/database"; /** @@ -9,10 +12,10 @@ import type { ListenOptions } from "firebase/database"; * pass Firebase {@link https://firebase.google.com/docs/reference/js/database.listoptions | ListenOptions} * via `database` (e.g. `{ onlyOnce: true }`). */ -export type DatabaseUseQueryOptions< - TData = unknown, - TError = Error, -> = Omit, "queryFn"> & { +export type DatabaseUseQueryOptions = Omit< + UseQueryOptions, + "queryFn" +> & { database?: ListenOptions; }; diff --git a/packages/react/src/database/useDatabaseSubscriptionQuery.ts b/packages/react/src/database/useDatabaseSubscriptionQuery.ts index 675cb62e..5e46a409 100644 --- a/packages/react/src/database/useDatabaseSubscriptionQuery.ts +++ b/packages/react/src/database/useDatabaseSubscriptionQuery.ts @@ -30,12 +30,7 @@ export function useDatabaseSubscriptionQuery( ...databaseSubscriptionQueryDefaults, ...queryOptions, queryFn: createDatabaseSubscriptionQueryFn((handlers) => - subscribeToQuery( - query, - handlers.onNext, - handlers.onError, - listenOptions, - ), + subscribeToQuery(query, handlers.onNext, handlers.onError, listenOptions), ), }); } diff --git a/packages/react/src/database/useOnChildChangedQuery.ts b/packages/react/src/database/useOnChildChangedQuery.ts index 6f9ac8fc..f7b411a6 100644 --- a/packages/react/src/database/useOnChildChangedQuery.ts +++ b/packages/react/src/database/useOnChildChangedQuery.ts @@ -1,5 +1,9 @@ import type { FirebaseError } from "firebase/app"; -import { type DataSnapshot, onChildChanged, type Query } from "firebase/database"; +import { + type DataSnapshot, + onChildChanged, + type Query, +} from "firebase/database"; import type { DatabaseUseQueryOptions } from "./types"; import { useDatabaseSubscriptionQuery } from "./useDatabaseSubscriptionQuery"; diff --git a/packages/react/src/database/useOnChildMovedQuery.test.tsx b/packages/react/src/database/useOnChildMovedQuery.test.tsx index e816e42c..3cd08131 100644 --- a/packages/react/src/database/useOnChildMovedQuery.test.tsx +++ b/packages/react/src/database/useOnChildMovedQuery.test.tsx @@ -1,5 +1,12 @@ import { renderHook, waitFor } from "@testing-library/react"; -import { child, orderByPriority, query, ref, set, setPriority } from "firebase/database"; +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"; diff --git a/packages/react/src/database/useOnChildRemovedQuery.ts b/packages/react/src/database/useOnChildRemovedQuery.ts index 958660a2..669c34ff 100644 --- a/packages/react/src/database/useOnChildRemovedQuery.ts +++ b/packages/react/src/database/useOnChildRemovedQuery.ts @@ -1,5 +1,9 @@ import type { FirebaseError } from "firebase/app"; -import { type DataSnapshot, onChildRemoved, type Query } from "firebase/database"; +import { + type DataSnapshot, + onChildRemoved, + type Query, +} from "firebase/database"; import type { DatabaseUseQueryOptions } from "./types"; import { useDatabaseSubscriptionQuery } from "./useDatabaseSubscriptionQuery"; diff --git a/packages/react/src/database/useRunTransactionMutation.ts b/packages/react/src/database/useRunTransactionMutation.ts index 2a9c319b..5f071bda 100644 --- a/packages/react/src/database/useRunTransactionMutation.ts +++ b/packages/react/src/database/useRunTransactionMutation.ts @@ -8,9 +8,7 @@ import { } from "firebase/database"; import type { DatabaseMutationOptions } from "./types"; -type TransactionUpdate = ( - currentData: unknown, -) => unknown; +type TransactionUpdate = (currentData: unknown) => unknown; type RunTransactionMutationOptions = DatabaseMutationOptions< TransactionResult, From 966a9b5935d6fdd6cdeb658abad636bc7f0405c5 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Tue, 26 May 2026 15:06:35 +0100 Subject: [PATCH 3/6] chore: add changeset for Realtime Database hooks Ensures @tanstack-query-firebase/react receives a minor version bump when the database hooks are released. --- .changeset/real-database-hooks.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/real-database-hooks.md 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 From 6d1e44e08d6810987dbe9805209afe9076ec6127 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Tue, 26 May 2026 15:08:02 +0100 Subject: [PATCH 4/6] docs(react): add Realtime Database documentation Document the new database hooks in the docs site, update navigation, and refresh the migration guide to reflect Realtime Database listener support. --- docs.json | 95 ++++++++++++ docs/react-query-firebase.mdx | 11 +- docs/react/database/hooks/useGetQuery.mdx | 18 +++ .../database/hooks/useGoOfflineMutation.mdx | 14 ++ .../database/hooks/useGoOnlineMutation.mdx | 14 ++ .../database/hooks/useOnChildAddedQuery.mdx | 17 +++ .../database/hooks/useOnChildChangedQuery.mdx | 17 +++ .../database/hooks/useOnChildMovedQuery.mdx | 17 +++ .../database/hooks/useOnChildRemovedQuery.mdx | 17 +++ .../hooks/useOnDisconnectCancelMutation.mdx | 16 ++ .../hooks/useOnDisconnectRemoveMutation.mdx | 16 ++ .../hooks/useOnDisconnectSetMutation.mdx | 16 ++ ...useOnDisconnectSetWithPriorityMutation.mdx | 16 ++ .../hooks/useOnDisconnectUpdateMutation.mdx | 16 ++ docs/react/database/hooks/useOnValueQuery.mdx | 17 +++ docs/react/database/hooks/usePushMutation.mdx | 16 ++ .../database/hooks/useRemoveMutation.mdx | 16 ++ .../hooks/useRunTransactionMutation.mdx | 19 +++ docs/react/database/hooks/useSetMutation.mdx | 16 ++ .../database/hooks/useSetPriorityMutation.mdx | 16 ++ .../hooks/useSetWithPriorityMutation.mdx | 16 ++ .../database/hooks/useUpdateMutation.mdx | 16 ++ docs/react/database/index.mdx | 138 ++++++++++++++++++ docs/react/index.mdx | 4 +- 24 files changed, 566 insertions(+), 8 deletions(-) create mode 100644 docs/react/database/hooks/useGetQuery.mdx create mode 100644 docs/react/database/hooks/useGoOfflineMutation.mdx create mode 100644 docs/react/database/hooks/useGoOnlineMutation.mdx create mode 100644 docs/react/database/hooks/useOnChildAddedQuery.mdx create mode 100644 docs/react/database/hooks/useOnChildChangedQuery.mdx create mode 100644 docs/react/database/hooks/useOnChildMovedQuery.mdx create mode 100644 docs/react/database/hooks/useOnChildRemovedQuery.mdx create mode 100644 docs/react/database/hooks/useOnDisconnectCancelMutation.mdx create mode 100644 docs/react/database/hooks/useOnDisconnectRemoveMutation.mdx create mode 100644 docs/react/database/hooks/useOnDisconnectSetMutation.mdx create mode 100644 docs/react/database/hooks/useOnDisconnectSetWithPriorityMutation.mdx create mode 100644 docs/react/database/hooks/useOnDisconnectUpdateMutation.mdx create mode 100644 docs/react/database/hooks/useOnValueQuery.mdx create mode 100644 docs/react/database/hooks/usePushMutation.mdx create mode 100644 docs/react/database/hooks/useRemoveMutation.mdx create mode 100644 docs/react/database/hooks/useRunTransactionMutation.mdx create mode 100644 docs/react/database/hooks/useSetMutation.mdx create mode 100644 docs/react/database/hooks/useSetPriorityMutation.mdx create mode 100644 docs/react/database/hooks/useSetWithPriorityMutation.mdx create mode 100644 docs/react/database/hooks/useUpdateMutation.mdx create mode 100644 docs/react/database/index.mdx 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`. From 157fdb9f293c3e45dad0eef1612f1fe6d3ff96a2 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Tue, 26 May 2026 15:40:25 +0100 Subject: [PATCH 5/6] fix(react): make Data Connect prefetch compatible with Firebase v12 Avoid serializing circular QueryRef objects during prefetch/dehydrate, and pin firebase-tools@14 in the v12 CI workflow to match the stable emulator setup used by the main test job. --- .github/workflows/test-react-firebase-v12.yml | 2 +- .../react/src/data-connect/query-client.ts | 18 +++--------------- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/.github/workflows/test-react-firebase-v12.yml b/.github/workflows/test-react-firebase-v12.yml index dc24f87d..2c34ad7b 100644 --- a/.github/workflows/test-react-firebase-v12.yml +++ b/.github/workflows/test-react-firebase-v12.yml @@ -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/packages/react/src/data-connect/query-client.ts b/packages/react/src/data-connect/query-client.ts index 97c4b117..f9bfbf58 100644 --- a/packages/react/src/data-connect/query-client.ts +++ b/packages/react/src/data-connect/query-client.ts @@ -28,12 +28,7 @@ export class DataConnectQueryClient extends QueryClient { if ("ref" in refOrResult) { queryRef = refOrResult.ref; - initialData = { - ...refOrResult.data, - ref: refOrResult.ref, - source: refOrResult.source, - fetchTime: refOrResult.fetchTime, - }; + initialData = JSON.parse(JSON.stringify(refOrResult.data)); } else { queryRef = refOrResult; } @@ -48,15 +43,8 @@ export class DataConnectQueryClient extends QueryClient { queryFn: async () => { const response = await executeQuery(queryRef); - const data = { - ...response.data, - ref: response.ref, - source: response.source, - fetchTime: response.fetchTime, - }; - - // Ensures no serialization issues with undefined values - return JSON.parse(JSON.stringify(data)); + // Only serialize query data. Firebase v12 QueryRef objects are circular. + return JSON.parse(JSON.stringify(response.data)); }, }); } From 18f5a01ac43b46bf3314c189c92d0f715cffc308 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Wed, 27 May 2026 08:56:53 +0100 Subject: [PATCH 6/6] revert: drop Data Connect prefetch changes from database hooks PR Keep Realtime Database work scoped to database hooks; Firebase v12 Data Connect compatibility will be handled separately. --- .../react/src/data-connect/query-client.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/react/src/data-connect/query-client.ts b/packages/react/src/data-connect/query-client.ts index f9bfbf58..97c4b117 100644 --- a/packages/react/src/data-connect/query-client.ts +++ b/packages/react/src/data-connect/query-client.ts @@ -28,7 +28,12 @@ export class DataConnectQueryClient extends QueryClient { if ("ref" in refOrResult) { queryRef = refOrResult.ref; - initialData = JSON.parse(JSON.stringify(refOrResult.data)); + initialData = { + ...refOrResult.data, + ref: refOrResult.ref, + source: refOrResult.source, + fetchTime: refOrResult.fetchTime, + }; } else { queryRef = refOrResult; } @@ -43,8 +48,15 @@ export class DataConnectQueryClient extends QueryClient { queryFn: async () => { const response = await executeQuery(queryRef); - // Only serialize query data. Firebase v12 QueryRef objects are circular. - return JSON.parse(JSON.stringify(response.data)); + const data = { + ...response.data, + ref: response.ref, + source: response.source, + fetchTime: response.fetchTime, + }; + + // Ensures no serialization issues with undefined values + return JSON.parse(JSON.stringify(data)); }, }); }