diff --git a/src/auth/useAuthState.ts b/src/auth/useAuthState.ts index f4a90ca4..0e6de15e 100644 --- a/src/auth/useAuthState.ts +++ b/src/auth/useAuthState.ts @@ -2,6 +2,7 @@ import { Auth, AuthError, onAuthStateChanged, User } from "firebase/auth"; import { useCallback } from "react"; import { ValueHookResult } from "../common"; import { useListen, UseListenOnChange } from "../internal/useListen"; +import { LoadingState } from "../internal/useLoadingValue"; export type UseAuthStateResult = ValueHookResult; @@ -20,5 +21,5 @@ export function useAuthState(auth: Auth): UseAuthStateResult { [] ); - return useListen(auth, onChange, () => true, auth.currentUser ?? undefined); + return useListen(auth, onChange, () => true, auth.currentUser ? auth.currentUser : LoadingState); } diff --git a/src/database/useObject.ts b/src/database/useObject.ts index 63209700..f4ebad81 100644 --- a/src/database/useObject.ts +++ b/src/database/useObject.ts @@ -1,6 +1,7 @@ import { DataSnapshot, onValue, Query } from "firebase/database"; import { ValueHookResult } from "../common"; import { useListen } from "../internal/useListen"; +import { LoadingState } from "../internal/useLoadingValue"; import { isQueryEqual } from "./internal"; export type UseObjectResult = ValueHookResult; @@ -15,5 +16,5 @@ export type UseObjectResult = ValueHookResult; * * error: `undefined` if no error occurred */ export function useObject(query: Query | undefined | null): UseObjectResult { - return useListen(query ?? undefined, onValue, isQueryEqual); + return useListen(query ?? undefined, onValue, isQueryEqual, LoadingState); } diff --git a/src/database/useObjectValue.ts b/src/database/useObjectValue.ts index a9f55a37..cbe9be15 100644 --- a/src/database/useObjectValue.ts +++ b/src/database/useObjectValue.ts @@ -2,6 +2,7 @@ import { DataSnapshot, onValue, Query } from "firebase/database"; import { useCallback } from "react"; import { ValueHookResult } from "../common"; import { useListen, UseListenOnChange } from "../internal/useListen"; +import { LoadingState } from "../internal/useLoadingValue"; import { isQueryEqual } from "./internal"; export type UseObjectValueResult = ValueHookResult; @@ -35,7 +36,5 @@ export function useObjectValue( [] ); - return useListen(query ?? undefined, onChange, isQueryEqual); + return useListen(query ?? undefined, onChange, isQueryEqual, LoadingState); } - -useObjectValue(null); diff --git a/src/firestore/useCollection.ts b/src/firestore/useCollection.ts index f93f8ba1..328be14b 100644 --- a/src/firestore/useCollection.ts +++ b/src/firestore/useCollection.ts @@ -2,6 +2,7 @@ import { DocumentData, FirestoreError, onSnapshot, Query, QuerySnapshot, Snapsho import { useCallback } from "react"; import { ValueHookResult } from "../common/types"; import { useListen, UseListenOnChange } from "../internal/useListen"; +import { LoadingState } from "../internal/useLoadingValue"; import { isQueryEqual } from "./internal"; export type UseCollectionResult = ValueHookResult< @@ -42,5 +43,5 @@ export function useCollection( [] ); - return useListen(query ?? undefined, onChange, isQueryEqual); + return useListen(query ?? undefined, onChange, isQueryEqual, LoadingState); } diff --git a/src/firestore/useCollectionData.ts b/src/firestore/useCollectionData.ts index 14d28553..8085320e 100644 --- a/src/firestore/useCollectionData.ts +++ b/src/firestore/useCollectionData.ts @@ -2,6 +2,7 @@ import { DocumentData, FirestoreError, onSnapshot, Query, SnapshotListenOptions, import { useCallback } from "react"; import { ValueHookResult } from "../common/types"; import { useListen, UseListenOnChange } from "../internal/useListen"; +import { LoadingState } from "../internal/useLoadingValue"; import { isQueryEqual } from "./internal"; export type UseCollectionDataResult = ValueHookResult; @@ -40,5 +41,5 @@ export function useCollectionData( [] ); - return useListen(query ?? undefined, onChange, isQueryEqual); + return useListen(query ?? undefined, onChange, isQueryEqual, LoadingState); } diff --git a/src/firestore/useDocument.ts b/src/firestore/useDocument.ts index 5f5b3bae..679350f6 100644 --- a/src/firestore/useDocument.ts +++ b/src/firestore/useDocument.ts @@ -9,6 +9,7 @@ import { import { useCallback } from "react"; import type { ValueHookResult } from "../common/types"; import { useListen, UseListenOnChange } from "../internal/useListen"; +import { LoadingState } from "../internal/useLoadingValue"; import { isDocRefEqual } from "./internal"; export type UseDocumentResult = ValueHookResult< @@ -49,5 +50,5 @@ export function useDocument( [] ); - return useListen(reference ?? undefined, onChange, isDocRefEqual); + return useListen(reference ?? undefined, onChange, isDocRefEqual, LoadingState); } diff --git a/src/firestore/useDocumentData.ts b/src/firestore/useDocumentData.ts index d32777d5..8f50d1db 100644 --- a/src/firestore/useDocumentData.ts +++ b/src/firestore/useDocumentData.ts @@ -9,6 +9,7 @@ import { import { useCallback } from "react"; import type { ValueHookResult } from "../common/types"; import { useListen, UseListenOnChange } from "../internal/useListen"; +import { LoadingState } from "../internal/useLoadingValue"; import { isDocRefEqual } from "./internal"; export type UseDocumentDataResult = ValueHookResult; @@ -47,5 +48,5 @@ export function useDocumentData( [] ); - return useListen(reference ?? undefined, onChange, isDocRefEqual); + return useListen(reference ?? undefined, onChange, isDocRefEqual, LoadingState); } diff --git a/src/internal/useListen.spec.ts b/src/internal/useListen.spec.ts index 8fc6949b..7465feba 100644 --- a/src/internal/useListen.spec.ts +++ b/src/internal/useListen.spec.ts @@ -1,6 +1,7 @@ import { act, renderHook } from "@testing-library/react-hooks"; import { newSymbol } from "../__testfixtures__"; import { useListen } from "./useListen"; +import { LoadingState } from "./useLoadingValue"; const result1 = newSymbol("Result 1"); const result2 = newSymbol("Result 2"); @@ -24,62 +25,99 @@ beforeEach(() => { }); describe("initial state", () => { - it("with undefined reference", () => { - const { result } = renderHook(() => useListen(undefined, onChange, isEqual)); + it.each` + reference | initialState | expectedValue | expectedLoading + ${undefined} | ${result1} | ${undefined} | ${false} + ${undefined} | ${undefined} | ${undefined} | ${false} + ${undefined} | ${LoadingState} | ${undefined} | ${false} + ${refA1} | ${result1} | ${result1} | ${false} + ${refA1} | ${undefined} | ${undefined} | ${false} + ${refA1} | ${LoadingState} | ${undefined} | ${true} + `( + "reference=$reference initialState=$initialState", + ({ reference, initialState, expectedValue, expectedLoading }: any) => { + const { result } = renderHook(() => useListen(reference, onChange, isEqual, initialState)); + expect(result.current).toStrictEqual([expectedValue, expectedLoading, undefined]); + } + ); +}); - expect(onChange).toHaveBeenCalledTimes(0); - expect(result.current).toStrictEqual([undefined, false, undefined]); - }); +describe("when changing ref", () => { + it("should not resubscribe for equal ref", async () => { + // first ref + const { result, rerender } = renderHook(({ ref }) => useListen(ref, onChange, isEqual, LoadingState), { + initialProps: { ref: refA1 }, + }); + expect(onChangeUnsubscribe).toHaveBeenCalledTimes(0); + expect(onChange).toHaveBeenCalledTimes(1); - it("with defined reference", () => { - const { result } = renderHook(() => useListen(refA1, onChange, isEqual)); + // emit value + act(() => onChange.mock.calls[0][1](result1)); + expect(result.current).toStrictEqual([result1, false, undefined]); + // change ref + rerender({ ref: refA2 }); + expect(result.current).toStrictEqual([result1, false, undefined]); + expect(onChangeUnsubscribe).toHaveBeenCalledTimes(0); expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange).toHaveBeenCalledWith(refA1, expect.any(Function), expect.any(Function)); - - expect(result.current).toStrictEqual([undefined, true, undefined]); }); - it("with default value", () => { - const { result } = renderHook(() => useListen(refA1, onChange, isEqual, result1)); - + it("should resubscribe for different ref", () => { + // first ref + const { result, rerender } = renderHook(({ ref }) => useListen(ref, onChange, isEqual, LoadingState), { + initialProps: { ref: refA1 }, + }); + expect(onChangeUnsubscribe).toHaveBeenCalledTimes(0); expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange).toHaveBeenCalledWith(refA1, expect.any(Function), expect.any(Function)); + // emit value + act(() => onChange.mock.calls[0][1](result1)); expect(result.current).toStrictEqual([result1, false, undefined]); + + // change ref + rerender({ ref: refB1 }); + expect(result.current).toStrictEqual([undefined, true, undefined]); + expect(onChange).toHaveBeenCalledTimes(2); + expect(onChangeUnsubscribe).toHaveBeenCalledTimes(1); + + // emit value + act(() => onChange.mock.calls[1][1](result2)); + expect(result.current).toStrictEqual([result2, false, undefined]); }); -}); -describe("when changing ref", () => { - it("should not resubscribe for equal ref", () => { - const { rerender } = renderHook(({ ref }) => useListen(ref, onChange, isEqual), { - initialProps: { ref: refA1 }, + it("from undefined ref to defined", () => { + const { result, rerender } = renderHook(({ ref }) => useListen(ref, onChange, isEqual, LoadingState), { + initialProps: { ref: undefined }, }); - expect(onChange).toHaveBeenCalledTimes(1); + expect(onChangeUnsubscribe).toHaveBeenCalledTimes(0); + expect(onChange).toHaveBeenCalledTimes(0); - rerender({ ref: refA2 }); + rerender({ ref: refA1 }); + expect(result.current).toStrictEqual([undefined, true, undefined]); + expect(onChangeUnsubscribe).toHaveBeenCalledTimes(0); expect(onChange).toHaveBeenCalledTimes(1); }); - it("should resubscribe for different ref", () => { - const { rerender } = renderHook(({ ref }) => useListen(ref, onChange, isEqual), { + it("from defined ref to undefined", () => { + const { result, rerender } = renderHook(({ ref }) => useListen(ref, onChange, isEqual, LoadingState), { initialProps: { ref: refA1 }, }); expect(onChangeUnsubscribe).toHaveBeenCalledTimes(0); expect(onChange).toHaveBeenCalledTimes(1); - rerender({ ref: refB1 }); + rerender({ ref: undefined }); + expect(result.current).toStrictEqual([undefined, false, undefined]); - expect(onChange).toHaveBeenCalledTimes(2); expect(onChangeUnsubscribe).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledTimes(1); }); }); it("should return emitted values", () => { - const { result } = renderHook(() => useListen(refA1, onChange, isEqual)); + const { result } = renderHook(() => useListen(refA1, onChange, isEqual, LoadingState)); const setValue = onChange.mock.calls[0][1]; expect(result.current).toStrictEqual([undefined, true, undefined]); @@ -96,7 +134,7 @@ it("should return emitted values", () => { }); it("should return emitted error", () => { - const { result } = renderHook(() => useListen(refA1, onChange, isEqual)); + const { result } = renderHook(() => useListen(refA1, onChange, isEqual, LoadingState)); const setValue = onChange.mock.calls[0][1]; const setError = onChange.mock.calls[0][2]; diff --git a/src/internal/useListen.ts b/src/internal/useListen.ts index 408af1fd..4c830a44 100644 --- a/src/internal/useListen.ts +++ b/src/internal/useListen.ts @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef } from "react"; import { ValueHookResult } from "../common"; -import { useLoadingValue } from "./useLoadingValue"; +import { useLoadingValue, LoadingState } from "./useLoadingValue"; import { useStableValue } from "./useStableValue"; /** @@ -19,16 +19,23 @@ export function useListen( reference: Reference | undefined, onChange: UseListenOnChange, isEqual: (a: Reference | undefined, b: Reference | undefined) => boolean, - defaultValue?: Value + initialState: Value | typeof LoadingState ): ValueHookResult { - const { error, loading, setLoading, setError, setValue, value } = useLoadingValue(defaultValue); + const { error, loading, setLoading, setError, setValue, value } = useLoadingValue( + reference === undefined ? undefined : initialState + ); const stableRef = useStableValue(reference ?? undefined, isEqual); const firstRender = useRef(true); useEffect(() => { if (stableRef === undefined) { - setValue(); + // value doesn't change on first render with undefined ref + if (firstRender.current) { + firstRender.current = false; + } else { + setValue(); + } } else { // do not set loading state on first render // otherwise, the defaultValue gets overwritten diff --git a/src/internal/useLoadingValue.spec.ts b/src/internal/useLoadingValue.spec.ts index 06aec834..5c07f3da 100644 --- a/src/internal/useLoadingValue.spec.ts +++ b/src/internal/useLoadingValue.spec.ts @@ -1,13 +1,13 @@ import { act, renderHook } from "@testing-library/react-hooks"; import { newSymbol } from "../__testfixtures__"; -import { useLoadingValue } from "./useLoadingValue"; +import { LoadingState, useLoadingValue } from "./useLoadingValue"; const value = newSymbol("Value"); const error = newSymbol("Error"); describe("initial state", () => { it("without default value", () => { - const { result } = renderHook(() => useLoadingValue()); + const { result } = renderHook(() => useLoadingValue(LoadingState)); expect(result.current.value).toBeUndefined(); expect(result.current.loading).toBe(true); @@ -21,6 +21,14 @@ describe("initial state", () => { expect(result.current.loading).toBe(false); expect(result.current.error).toBeUndefined(); }); + + it("with default value undefined", () => { + const { result } = renderHook(() => useLoadingValue(undefined)); + + expect(result.current.value).toBe(undefined); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeUndefined(); + }); }); describe("setValue", () => { @@ -34,7 +42,7 @@ describe("setValue", () => { }); it("with a value", () => { - const { result } = renderHook(() => useLoadingValue()); + const { result } = renderHook(() => useLoadingValue(LoadingState)); act(() => result.current.setValue(value)); expect(result.current.value).toBe(value); @@ -43,7 +51,7 @@ describe("setValue", () => { }); it("with error", () => { - const { result } = renderHook(() => useLoadingValue()); + const { result } = renderHook(() => useLoadingValue(LoadingState)); act(() => result.current.setError(error)); act(() => result.current.setValue(value)); @@ -56,7 +64,7 @@ describe("setValue", () => { describe("setError", () => { it("without value", () => { - const { result } = renderHook(() => useLoadingValue()); + const { result } = renderHook(() => useLoadingValue(LoadingState)); act(() => result.current.setError(error)); expect(result.current.value).toBeUndefined(); diff --git a/src/internal/useLoadingValue.ts b/src/internal/useLoadingValue.ts index 08a04b83..fdbe1878 100644 --- a/src/internal/useLoadingValue.ts +++ b/src/internal/useLoadingValue.ts @@ -1,5 +1,7 @@ import { useCallback, useMemo, useState } from "react"; +export const LoadingState = Symbol(); + /** * @internal */ @@ -13,7 +15,7 @@ interface State { * @internal */ export interface UseLoadingValueResult { - value?: Value; + value: Value | undefined; setValue: (value?: Value) => void; loading: boolean; setLoading: () => void; @@ -24,11 +26,13 @@ export interface UseLoadingValueResult { /** * @internal */ -export function useLoadingValue(defaultValue?: Value): UseLoadingValueResult { +export function useLoadingValue( + initialState: Value | undefined | typeof LoadingState +): UseLoadingValueResult { const [state, setState] = useState>({ error: undefined, - loading: defaultValue === undefined ? true : false, - value: defaultValue, + loading: initialState === LoadingState ? true : false, + value: initialState === LoadingState ? undefined : initialState, }); const setValue = useCallback((value?: Value) => { diff --git a/src/internal/useOnce.spec.ts b/src/internal/useOnce.spec.ts index be315bae..e04eb7f6 100644 --- a/src/internal/useOnce.spec.ts +++ b/src/internal/useOnce.spec.ts @@ -1,5 +1,7 @@ import { renderHook } from "@testing-library/react-hooks"; import { newPromise, newSymbol } from "../__testfixtures__"; +import { useListen } from "./useListen"; +import { LoadingState } from "./useLoadingValue"; import { useOnce } from "./useOnce"; const result1 = newSymbol("Result 1"); @@ -21,18 +23,21 @@ beforeEach(() => { }); describe("initial state", () => { - it("should return loading=false for undefined ref", () => { - const { result } = renderHook(() => useOnce(undefined, getData, isEqual)); - - expect(result.current).toStrictEqual([undefined, false, undefined]); - }); - - it("should return loading=true for defined ref", () => { - getData.mockImplementation(() => newPromise().promise); - - const { result } = renderHook(() => useOnce(refA1, getData, isEqual)); - expect(result.current).toStrictEqual([undefined, true, undefined]); - }); + it.each` + reference | initialState | expectedValue | expectedLoading + ${undefined} | ${result1} | ${undefined} | ${false} + ${undefined} | ${undefined} | ${undefined} | ${false} + ${undefined} | ${LoadingState} | ${undefined} | ${false} + ${refA1} | ${result1} | ${result1} | ${false} + ${refA1} | ${undefined} | ${undefined} | ${false} + ${refA1} | ${LoadingState} | ${undefined} | ${true} + `( + "reference=$reference initialState=$initialState", + ({ reference, initialState, expectedValue, expectedLoading }: any) => { + const { result } = renderHook(() => useListen(reference, getData, isEqual, initialState)); + expect(result.current).toStrictEqual([expectedValue, expectedLoading, undefined]); + } + ); }); describe("initial load", () => { diff --git a/src/internal/useOnce.ts b/src/internal/useOnce.ts index 8fbc11ca..e7bca499 100644 --- a/src/internal/useOnce.ts +++ b/src/internal/useOnce.ts @@ -1,7 +1,7 @@ import { useEffect, useMemo } from "react"; import { ValueHookResult } from "../common"; import { useIsMounted } from "./useIsMounted"; -import { useLoadingValue } from "./useLoadingValue"; +import { LoadingState, useLoadingValue } from "./useLoadingValue"; import { useStableValue } from "./useStableValue"; /** @@ -13,7 +13,9 @@ export function useOnce( isEqual: (a: Reference | undefined, b: Reference | undefined) => boolean ): ValueHookResult { const isMounted = useIsMounted(); - const { value, setValue, loading, setLoading, error, setError } = useLoadingValue(); + const { value, setValue, loading, setLoading, error, setError } = useLoadingValue( + reference === undefined ? undefined : LoadingState + ); const stableRef = useStableValue(reference ?? undefined, isEqual);