diff --git a/packages/realm-react/CHANGELOG.md b/packages/realm-react/CHANGELOG.md index 95fd5497e0..759a80ebb4 100644 --- a/packages/realm-react/CHANGELOG.md +++ b/packages/realm-react/CHANGELOG.md @@ -1,10 +1,19 @@ ## vNext (TBD) ### Deprecations -* None +* Deprecated calling `useQuery` with three positional arguments. ([#6360](https://github.com/realm/realm-js/pull/6360)) + + Please pass a single "options" object followed by an optional `deps` argument (if your query depends on any local variables): + ```tsx + const filteredAndSorted = useQuery({ + type: Object, + query: (collection) => collection.filtered('category == $0',category).sorted('name'), + }, [category]); + ``` ### Enhancements -* None +* Adding the ability to pass "options" to `useQuery` and `useObject` as an object. ([#6360](https://github.com/realm/realm-js/pull/6360)) +* Adding `keyPaths` option to the `useQuery` and `useObject` hooks, to indicate a lower bound on the changes relevant for the hook. This is a lower bound, since if multiple hooks add listeners (each with their own `keyPaths`) the union of these key-paths will determine the changes that are considered relevant for all listeners registered on the collection or object. In other words: A listener might fire and cause a re-render more than the key-paths specify, if other listeners with different key-paths are present. ([#6360](https://github.com/realm/realm-js/pull/6360)) ### Fixed * Removed race condition in `useObject` ([#6291](https://github.com/realm/realm-js/issues/6291)) Thanks [@bimusik](https://github.com/bimusiek)! diff --git a/packages/realm-react/README.md b/packages/realm-react/README.md index f1ab2732c5..4ae1e9aa64 100644 --- a/packages/realm-react/README.md +++ b/packages/realm-react/README.md @@ -155,10 +155,13 @@ import {useQuery} from '@realm/react'; const Component = () => { // ObjectClass is a class extending Realm.Object, which should have been provided in the Realm Config. // It is also possible to use the model's name as a string ( ex. "Object" ) if you are not using class based models. - const sortedCollection = useQuery(ObjectClass, (collection) => { - // The methods `sorted` and `filtered` should be passed as a `query` function. - // Any variables that are dependencies of this should be placed in the dependency array. - return collection.sorted(); + const sortedCollection = useQuery({ + type: ObjectClass, + query: (collection) => { + // The methods `sorted` and `filtered` should be passed as a `query` function. + // Any variables that are dependencies of this should be placed in the dependency array. + return collection.sorted(); + } }, []); return ( diff --git a/packages/realm-react/src/__tests__/RealmProvider.test.tsx b/packages/realm-react/src/__tests__/RealmProvider.test.tsx index 55e299bcf7..63709d2d7c 100644 --- a/packages/realm-react/src/__tests__/RealmProvider.test.tsx +++ b/packages/realm-react/src/__tests__/RealmProvider.test.tsx @@ -23,6 +23,7 @@ import { act, fireEvent, render, renderHook, waitFor } from "@testing-library/re import { createRealmContext } from ".."; import { areConfigurationsIdentical, mergeRealmConfiguration } from "../RealmProvider"; +import { randomRealmPath } from "./helpers"; const dogSchema: Realm.ObjectSchema = { name: "dog", @@ -45,7 +46,7 @@ const catSchema: Realm.ObjectSchema = { const { RealmProvider, useRealm } = createRealmContext({ schema: [dogSchema], inMemory: true, - path: "testArtifacts/realm-provider.realm", + path: randomRealmPath(), }); const EmptyRealmContext = createRealmContext(); diff --git a/packages/realm-react/src/__tests__/createRealmTestContext.ts b/packages/realm-react/src/__tests__/createRealmTestContext.ts new file mode 100644 index 0000000000..d3a7109bb5 --- /dev/null +++ b/packages/realm-react/src/__tests__/createRealmTestContext.ts @@ -0,0 +1,68 @@ +//////////////////////////////////////////////////////////////////////////// +// +// Copyright 2024 Realm Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//////////////////////////////////////////////////////////////////////////// + +import assert from "node:assert"; +import Realm, { Configuration } from "realm"; +import { act } from "@testing-library/react-native"; + +import { randomRealmPath } from "./helpers"; + +export type RealmTestContext = { + realm: Realm; + useRealm: () => Realm; + write(cb: () => void): void; + openRealm(config?: Configuration): Realm; + cleanup(): void; +}; + +/** + * Opens a test realm at a randomized and temporary path. + * @returns The `realm` and a `write` function, which will wrap `realm.write` with an `act` and prepand a second `realm.write` to force notifications to trigger synchronously. + */ +export function createRealmTestContext(rootConfig: Configuration = {}): RealmTestContext { + let realm: Realm | null = null; + const context = { + get realm(): Realm { + assert(realm, "Open the Realm first"); + return realm; + }, + useRealm() { + return context.realm; + }, + openRealm(config: Configuration = {}) { + if (realm) { + // Close any realm, previously opened + realm.close(); + } + realm = new Realm({ ...rootConfig, ...config, path: randomRealmPath() }); + return realm; + }, + write(callback: () => void) { + act(() => { + context.realm.write(callback); + // Starting another write transaction will force notifications to fire synchronously + context.realm.beginTransaction(); + context.realm.cancelTransaction(); + }); + }, + cleanup() { + Realm.clearTestState(); + }, + }; + return context; +} diff --git a/packages/realm-react/src/__tests__/helpers.ts b/packages/realm-react/src/__tests__/helpers.ts index 54d6e00a0b..a0cfe9832d 100644 --- a/packages/realm-react/src/__tests__/helpers.ts +++ b/packages/realm-react/src/__tests__/helpers.ts @@ -16,6 +16,9 @@ // //////////////////////////////////////////////////////////////////////////// +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; import { AppConfig, AppImporter, Credentials } from "@realm/app-importer"; import { act, waitFor } from "@testing-library/react-native"; @@ -68,3 +71,8 @@ const importer = new AppImporter({ export async function importApp(config: AppConfig): Promise<{ appId: string }> { return importer.importApp(config); } + +export function randomRealmPath() { + const tempDirPath = fs.mkdtempSync(path.join(os.tmpdir(), "realm-react-tests-")); + return path.join(tempDirPath, "test.realm"); +} diff --git a/packages/realm-react/src/__tests__/profileHook.tsx b/packages/realm-react/src/__tests__/profileHook.tsx new file mode 100644 index 0000000000..572e8ec475 --- /dev/null +++ b/packages/realm-react/src/__tests__/profileHook.tsx @@ -0,0 +1,76 @@ +//////////////////////////////////////////////////////////////////////////// +// +// Copyright 2024 Realm Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +//////////////////////////////////////////////////////////////////////////// + +import React, { Profiler, ProfilerOnRenderCallback } from "react"; +import { renderHook, RenderHookResult, RenderHookOptions } from "@testing-library/react-native"; + +function generateProfilerId() { + const nonce = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); + return `test-${nonce}`; +} + +type RenderEvent = { + phase: "mount" | "update"; + actualDuration: number; + baseDuration: number; +}; + +type ProfileWrapper = { + wrapper: React.ComponentType; + renders: RenderEvent[]; +}; + +function createProfilerWrapper(Parent: undefined | React.ComponentType): ProfileWrapper { + const renders: RenderEvent[] = []; + const id = generateProfilerId(); + const handleRender: ProfilerOnRenderCallback = ( + id, + phase, + actualDuration, + baseDuration, + startTime, + commitTime, + interactions, + ) => { + renders.push({ phase, actualDuration, baseDuration }); + }; + + const Wrapper: React.ComponentType = ({ children }) => ( + + ); + + return { + wrapper: Parent + ? ({ children }) => ( + + + + ) + : Wrapper, + renders, + }; +} + +export function profileHook( + callback: (props: Props) => Result, + options?: RenderHookOptions, +): RenderHookResult & { renders: RenderEvent[] } { + const { wrapper, renders } = createProfilerWrapper(options?.wrapper); + const result = renderHook(callback, { ...options, wrapper }); + return { ...result, renders }; +} diff --git a/packages/realm-react/src/__tests__/useObjectHook.test.tsx b/packages/realm-react/src/__tests__/useObjectHook.test.tsx index b01e9f3719..614c1f6d12 100644 --- a/packages/realm-react/src/__tests__/useObjectHook.test.tsx +++ b/packages/realm-react/src/__tests__/useObjectHook.test.tsx @@ -16,11 +16,13 @@ // //////////////////////////////////////////////////////////////////////////// -import { useEffect, useState } from "react"; import Realm from "realm"; import { renderHook } from "@testing-library/react-native"; +import assert from "node:assert"; import { createUseObject } from "../useObject"; +import { createRealmTestContext } from "./createRealmTestContext"; +import { profileHook } from "./profileHook"; const dogSchema: Realm.ObjectSchema = { name: "dog", @@ -28,69 +30,129 @@ const dogSchema: Realm.ObjectSchema = { properties: { _id: "int", name: "string", + age: "int", }, }; interface IDog { _id: number; name: string; + age: number; } -const configuration = { - schema: [dogSchema], - path: "testArtifacts/use-object-hook.realm", -}; - -const useRealm = () => { - const [realm, setRealm] = useState(new Realm(configuration)); - useEffect(() => { - return () => { - realm.close(); - }; - }, [realm, setRealm]); - - return new Realm(configuration); -}; - -const useObject = createUseObject(useRealm); +const context = createRealmTestContext({ schema: [dogSchema] }); +const useObject = createUseObject(context.useRealm); const testDataSet = [ - { _id: 4, name: "Vincent" }, - { _id: 5, name: "River" }, - { _id: 6, name: "Schatzi" }, + { _id: 4, name: "Vincent", age: 5 }, + { _id: 5, name: "River", age: 25 }, + { _id: 6, name: "Schatzi", age: 13 }, ]; -describe("useObject hook", () => { +describe("useObject", () => { beforeEach(() => { - const realm = new Realm(configuration); + const realm = context.openRealm(); realm.write(() => { realm.deleteAll(); testDataSet.forEach((data) => { realm.create("dog", data); }); }); - realm.close(); }); afterEach(() => { - Realm.clearTestState(); + context.cleanup(); }); it("can retrieve a single object using useObject", () => { - const [, dog2] = testDataSet; + const [, river] = testDataSet; + const { result } = renderHook(() => useObject("dog", river._id)); + const object = result.current; + expect(object).toMatchObject(river); + }); - const { result } = renderHook(() => useObject("dog", dog2._id)); + describe("missing objects", () => { + it("return null", () => { + const { result } = renderHook(() => useObject("dog", 12)); + expect(result.current).toEqual(null); + }); - const object = result.current; + it("rerenders and return object once created", () => { + const { write, realm } = context; + const { result, renders } = profileHook(() => useObject("dog", 12)); + expect(renders).toHaveLength(1); + expect(result.current).toEqual(null); + write(() => { + realm.create("dog", { _id: 12, name: "Lassie", age: 32 }); + }); + expect(renders).toHaveLength(2); + expect(result.current?.name).toEqual("Lassie"); + }); + }); - expect(object).toMatchObject(dog2); + describe("key-path filtering", () => { + it("can filter notifications using key-path array", async () => { + const [vincent] = testDataSet; + const { write } = context; + const { result, renders } = profileHook(() => useObject("dog", vincent._id, ["name"])); + expect(renders).toHaveLength(1); + expect(result.current).toMatchObject(vincent); + // Update the name and expect a re-render + write(() => { + assert(result.current); + result.current.name = "Vince!"; + }); + expect(renders).toHaveLength(2); + expect(result.current?.name).toEqual("Vince!"); + // Update the age and don't expect a re-render + write(() => { + assert(result.current); + result.current.age = 5; + }); + expect(renders).toHaveLength(2); + }); + + it("can filter notifications using key-path string", async () => { + const [vincent] = testDataSet; + const { write } = context; + const { result, renders } = profileHook(() => useObject("dog", vincent._id, "age")); + expect(renders).toHaveLength(1); + expect(result.current).toMatchObject(vincent); + // Update the name and expect a re-render + write(() => { + assert(result.current); + result.current.age = 13; + }); + expect(renders).toHaveLength(2); + expect(result.current?.age).toEqual(13); + // Update the age and don't expect a re-render + write(() => { + assert(result.current); + result.current.name = "Vince!"; + }); + expect(renders).toHaveLength(2); + }); }); - it("object is null", () => { - const { result } = renderHook(() => useObject("dog", 12)); + describe("passing options object as argument", () => { + it("rerenders on updates", () => { + const { write, realm } = context; - const object = result.current; + const vincent = realm.objectForPrimaryKey("dog", 4); + assert(vincent); - expect(object).toEqual(null); + const { result, renders } = profileHook(() => useObject({ type: "dog", primaryKey: 4, keyPaths: "name" })); + expect(renders).toHaveLength(1); + write(() => { + vincent.name = "Vinnie"; + }); + expect(renders).toHaveLength(2); + expect(result.current?.name).toEqual("Vinnie"); + // Expect no renders when updating a property outside key-paths + write(() => { + vincent.age = 30; + }); + expect(renders).toHaveLength(2); + }); }); }); diff --git a/packages/realm-react/src/__tests__/useObjectRender.test.tsx b/packages/realm-react/src/__tests__/useObjectRender.test.tsx index 5a79f3e9eb..c52afc05a1 100644 --- a/packages/realm-react/src/__tests__/useObjectRender.test.tsx +++ b/packages/realm-react/src/__tests__/useObjectRender.test.tsx @@ -22,6 +22,7 @@ import { FlatList, ListRenderItem, Text, TextInput, TouchableHighlight, View } f import Realm from "realm"; import { createUseObject } from "../useObject"; +import { randomRealmPath } from "./helpers"; export class ListItem extends Realm.Object { id!: Realm.BSON.ObjectId; @@ -66,7 +67,7 @@ export class List extends Realm.Object { const configuration: Realm.Configuration = { schema: [List, ListItem], deleteRealmIfMigrationNeeded: true, - path: "testArtifacts/use-object-render.realm", + path: randomRealmPath(), }; // TODO: It would be better not to have to rely on this, but at the moment I see no other alternatives diff --git a/packages/realm-react/src/__tests__/useQueryHook.test.tsx b/packages/realm-react/src/__tests__/useQueryHook.test.tsx index 0d9dcc5987..8c23d1cf81 100644 --- a/packages/realm-react/src/__tests__/useQueryHook.test.tsx +++ b/packages/realm-react/src/__tests__/useQueryHook.test.tsx @@ -17,10 +17,11 @@ //////////////////////////////////////////////////////////////////////////// import Realm from "realm"; -import { useEffect, useState } from "react"; import { renderHook } from "@testing-library/react-native"; import { createUseQuery } from "../useQuery"; +import { profileHook } from "./profileHook"; +import { createRealmTestContext } from "./createRealmTestContext"; const dogSchema: Realm.ObjectSchema = { name: "dog", @@ -41,24 +42,6 @@ interface IDog { age: number; gender: string; } -const configuration: Realm.Configuration = { - schema: [dogSchema], - path: "testArtifacts/use-query-hook.realm", - deleteRealmIfMigrationNeeded: true, -}; - -const useRealm = () => { - const [realm, setRealm] = useState(new Realm(configuration)); - useEffect(() => { - return () => { - realm.close(); - }; - }, [realm, setRealm]); - - return new Realm(configuration); -}; - -const useQuery = createUseQuery(useRealm); const testDataSet = [ { _id: 1, name: "Vincent", color: "black and white", gender: "male", age: 4 }, @@ -69,20 +52,22 @@ const testDataSet = [ { _id: 6, name: "Sadie", color: "gold", gender: "female", age: 5 }, ]; -describe("useQueryHook", () => { +describe("useQuery", () => { + const context = createRealmTestContext({ schema: [dogSchema] }); + const useQuery = createUseQuery(context.useRealm); + beforeEach(() => { - const realm = new Realm(configuration); + const realm = context.openRealm(); realm.write(() => { realm.deleteAll(); testDataSet.forEach((data) => { realm.create("dog", data); }); }); - realm.close(); }); afterEach(() => { - Realm.clearTestState(); + context.cleanup(); }); it("can retrieve collections using useQuery", () => { @@ -111,4 +96,105 @@ describe("useQueryHook", () => { expect(collection[99]).toBe(undefined); }); + + it("can filter objects via type and callback", () => { + const { result } = renderHook(() => useQuery("dog", (dogs) => dogs.filtered("age > 10"))); + expect(result.current.length).toBe(3); + }); + + describe("passing an object of options as argument", () => { + it("can filter objects via a 'query' property", () => { + const { result, renders } = profileHook(() => + useQuery({ + type: "dog", + query: (dogs) => dogs.filtered("age > 10"), + }), + ); + expect(result.current.length).toBe(3); + expect(renders).toHaveLength(1); + }); + + it("can update filter objects via a 'query' property", () => { + const { result, renders, rerender } = profileHook( + ({ age }) => + useQuery( + { + type: "dog", + query: (dogs) => dogs.filtered("age > $0", age), + }, + [age], + ), + { initialProps: { age: 10 } }, + ); + expect(result.current.length).toBe(3); + expect(renders).toHaveLength(1); + + // Update the query to filter for a different age + rerender({ age: 15 }); + expect(result.current.length).toBe(1); + expect(renders).toHaveLength(2); + }); + + it("can filter notifications using key-path array", async () => { + const { write } = context; + const { result, renders } = profileHook(() => + useQuery({ + type: "dog", + query: (dogs) => dogs.filtered("age > 10"), + keyPaths: ["name"], + }), + ); + + const initialCollection = result.current; + expect(initialCollection).toHaveLength(3); + expect(renders).toHaveLength(1); + + // Updating a name in the database and expect a render + const [firstDog] = result.current; + expect(firstDog.name).toEqual("River"); + write(() => { + firstDog.name = "Rivery!"; + }); + expect(renders).toHaveLength(2); + expect(initialCollection).not.toBe(result.current); + + // Updating an age in the database and don't expect a render + expect(firstDog.age).toEqual(12); + write(() => { + firstDog.age = 13; + }); + expect(renders).toHaveLength(2); + }); + + it("can filter notifications using key-path string", async () => { + const { write } = context; + const { result, renders } = profileHook(() => + useQuery({ + type: "dog", + query: (dogs) => dogs.filtered("name != $0", "Vincent"), + keyPaths: "age", + }), + ); + + const initialCollection = result.current; + expect(initialCollection).toHaveLength(5); + expect(renders).toHaveLength(1); + + // Updating an age in the database and expect a render + const [firstDog] = result.current; + expect(firstDog.name).toEqual("River"); + write(() => { + firstDog.age = 16; + }); + expect(renders).toHaveLength(2); + expect(initialCollection).not.toBe(result.current); + + // Updating a name in the database and don't expect a render + expect(firstDog.age).toEqual(16); + write(() => { + firstDog.name = "Rivery!"; + }); + expect(renders).toHaveLength(2); + }); + }); }); diff --git a/packages/realm-react/src/__tests__/useQueryRender.test.tsx b/packages/realm-react/src/__tests__/useQueryRender.test.tsx index 7f6a2d93b1..906d71d87f 100644 --- a/packages/realm-react/src/__tests__/useQueryRender.test.tsx +++ b/packages/realm-react/src/__tests__/useQueryRender.test.tsx @@ -22,6 +22,7 @@ import { act, fireEvent, render, waitFor } from "@testing-library/react-native"; import { FlatList, ListRenderItem, Text, TextInput, TouchableHighlight, View } from "react-native"; import { createRealmContext } from ".."; +import { randomRealmPath } from "./helpers"; class Item extends Realm.Object { id!: number; @@ -66,7 +67,7 @@ const testCollection = [...new Array(30)].map((_, index) => ({ id: index, name: const configuration: Realm.Configuration = { schema: [Item, Tag], inMemory: true, - path: "testArtifacts/use-query-rerender.realm", + path: randomRealmPath(), }; const itemRenderCounter = jest.fn(); diff --git a/packages/realm-react/src/cachedCollection.ts b/packages/realm-react/src/cachedCollection.ts index 9cf6501532..49ecc456b8 100644 --- a/packages/realm-react/src/cachedCollection.ts +++ b/packages/realm-react/src/cachedCollection.ts @@ -27,17 +27,19 @@ function getCacheKey(id: string) { } /** - * Arguments object for {@link cachedCollection}. + * Arguments object for {@link createCachedCollection}. */ type CachedCollectionArgs = { /** * The {@link Realm.Collection} to proxy */ collection: Realm.List | Realm.Results; + /** * The {@link Realm} instance */ realm: Realm; + /** * Callback which is called whenever an object in the collection changes */ @@ -56,12 +58,18 @@ type CachedCollectionArgs = { * references being created. */ objectCache?: Map; + /** * Optional flag specifying that this is a derived (`sorted` or `filtered`) version of * an existing collection, so we should not create or remove listeners or clear the cache * when this is torn down. */ isDerived?: boolean; + + /** + * Optional list of key-paths to limit notifications. + */ + keyPaths?: string[]; }; /** @@ -82,6 +90,7 @@ export function createCachedCollection>({ updatedRef, objectCache = new Map(), isDerived = false, + keyPaths, }: CachedCollectionArgs): { collection: Realm.Results | Realm.List; tearDown: () => void } { const cachedCollectionHandler: ProxyHandler | Realm.List> = { get: function (target, key, receiver) { @@ -98,6 +107,7 @@ export function createCachedCollection>({ updatedRef, objectCache, isDerived: true, + keyPaths, }); return newCol; }; @@ -177,10 +187,10 @@ export function createCachedCollection>({ // see https://github.com/realm/realm-js/issues/4375 if (realm.isInTransaction) { setImmediate(() => { - collection.addListener(listenerCallback); + collection.addListener(listenerCallback, keyPaths); }); } else { - collection.addListener(listenerCallback); + collection.addListener(listenerCallback, keyPaths); } } diff --git a/packages/realm-react/src/cachedObject.ts b/packages/realm-react/src/cachedObject.ts index 7620e10244..2b7ff8c981 100644 --- a/packages/realm-react/src/cachedObject.ts +++ b/packages/realm-react/src/cachedObject.ts @@ -44,6 +44,11 @@ type CachedObjectArgs = { * The implementing component should reset this to false when updating its object reference */ updatedRef: React.MutableRefObject; + + /** + * Optional list of key-paths to limit notifications. + */ + keyPaths?: string[]; }; export type CachedObject = { @@ -62,7 +67,13 @@ export type CachedObject = { * @param args - {@link CachedObjectArgs} object arguments * @returns Proxy object wrapping the {@link Realm.Object} */ -export function createCachedObject({ object, realm, updateCallback, updatedRef }: CachedObjectArgs): CachedObject { +export function createCachedObject({ + object, + realm, + updateCallback, + updatedRef, + keyPaths, +}: CachedObjectArgs): CachedObject { const listCaches = new Map(); const listTearDowns: Array<() => void> = []; // If the object doesn't exist, just return it with an noop tearDown @@ -135,10 +146,10 @@ export function createCachedObject({ object, realm, updateCallback, updatedRef } // see https://github.com/realm/realm-js/issues/4375 if (realm.isInTransaction) { setImmediate(() => { - object.addListener(listenerCallback); + object.addListener(listenerCallback, keyPaths); }); } else { - object.addListener(listenerCallback); + object.addListener(listenerCallback, keyPaths); } } diff --git a/packages/realm-react/src/helpers.ts b/packages/realm-react/src/helpers.ts index 34637b1afb..61f2a9b13a 100644 --- a/packages/realm-react/src/helpers.ts +++ b/packages/realm-react/src/helpers.ts @@ -42,3 +42,12 @@ export function getObjects( } export type CollectionCallback = Parameters[0]; + +/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ +export type AnyRealmObject = Realm.Object; + +export type RealmClassType = { new (...args: any): T }; + +export function isClassModelConstructor(value: unknown): value is RealmClassType { + return Object.getPrototypeOf(value) === Realm.Object; +} diff --git a/packages/realm-react/src/index.tsx b/packages/realm-react/src/index.tsx index e689e7ff8c..d6f36b118c 100644 --- a/packages/realm-react/src/index.tsx +++ b/packages/realm-react/src/index.tsx @@ -17,13 +17,16 @@ //////////////////////////////////////////////////////////////////////////// import Realm from "realm"; -import { createContext } from "react"; +import React, { createContext } from "react"; import { createRealmProvider } from "./RealmProvider"; import { createUseObject } from "./useObject"; import { createUseQuery } from "./useQuery"; import { createUseRealm } from "./useRealm"; +export type { UseObjectHook } from "./useObject"; +export type { UseQueryHook, QueryHookOptions, QueryHookClassBasedOptions } from "./useQuery"; + type RealmContext = { /** * The Provider component that is required to wrap any component using @@ -60,6 +63,7 @@ type RealmContext = { * @returns a realm instance */ useRealm: ReturnType; + /** * Returns a {@link Realm.Collection} of {@link Realm.Object}s from a given type. * The hook will update on any changes to any object in the collection @@ -71,14 +75,25 @@ type RealmContext = { * @example * ```tsx * // Return all collection items - * const collection = useQuery(Object) + * const collection = useQuery({ type: Object }); * * // Return all collection items sorted by name and filtered by category - * const filteredAndSorted = useQuery(Object, (collection) => collection.filtered('category == $0',category).sorted('name'), [category]); + * const filteredAndSorted = useQuery({ + * type: Object, + * query: (collection) => collection.filtered('category == $0',category).sorted('name'), + * }, [category]); + * + * // Return all collection items sorted by name and filtered by category, triggering re-renders only if "name" changes + * const filteredAndSorted = useQuery({ + * type: Object, + * query: (collection) => collection.filtered('category == $0',category).sorted('name'), + * keyPaths: ["name"] + * }, [category]); * ``` - * @param type - The object type, depicted by a string or a class extending Realm.Object - * @param query - A function that takes a {@link Realm.Collection} and returns a {@link Realm.Collection} of the same type. - * This allows for filtering and sorting of the collection, before it is returned. + * @param options + * @param options.type - The object type, depicted by a string or a class extending Realm.Object + * @param options.query - A function that takes a {@link Realm.Collection} and returns a {@link Realm.Collection} of the same type. This allows for filtering and sorting of the collection, before it is returned. + * @param options.keyPaths - Indicates a lower bound on the changes relevant for the hook. This is a lower bound, since if multiple hooks add listeners (each with their own `keyPaths`) the union of these key-paths will determine the changes that are considered relevant for all listeners registered on the collection. In other words: A listener might fire and cause a re-render more than the key-paths specify, if other listeners with different key-paths are present. * @param deps - An array of dependencies that will be passed to {@link React.useMemo} * @returns a collection of realm objects or an empty array */ @@ -93,6 +108,7 @@ type RealmContext = { * ``` * @param type - The object type, depicted by a string or a class extending {@link Realm.Object} * @param primaryKey - The primary key of the desired object which will be retrieved using {@link Realm.objectForPrimaryKey} + * @param keyPaths - Indicates a lower bound on the changes relevant for the hook. This is a lower bound, since if multiple hooks add listeners (each with their own `keyPaths`) the union of these key-paths will determine the changes that are considered relevant for all listeners registered on the object. In other words: A listener might fire and cause a re-render more than the key-paths specify, if other listeners with different key-paths are present. * @returns either the desired {@link Realm.Object} or `null` in the case of it being deleted or not existing. */ useObject: ReturnType; @@ -183,14 +199,25 @@ export const useRealm = defaultContext.useRealm; * @example * ```tsx * // Return all collection items - * const collection = useQuery(Object) + * const collection = useQuery({ type: Object }); * * // Return all collection items sorted by name and filtered by category - * const filteredAndSorted = useQuery(Object, (collection) => collection.filtered('category == $0',category).sorted('name'), [category]); + * const filteredAndSorted = useQuery({ + * type: Object, + * query: (collection) => collection.filtered('category == $0',category).sorted('name'), + * }, [category]); + * + * // Return all collection items sorted by name and filtered by category, triggering re-renders only if "name" changes + * const filteredAndSorted = useQuery({ + * type: Object, + * query: (collection) => collection.filtered('category == $0',category).sorted('name'), + * keyPaths: ["name"] + * }, [category]); * ``` - * @param type - The object type, depicted by a string or a class extending Realm.Object - * @param query - A function that takes a {@link Realm.Collection} and returns a {@link Realm.Collection} of the same type. - * This allows for filtering and sorting of the collection, before it is returned. + * @param options + * @param options.type - The object type, depicted by a string or a class extending Realm.Object + * @param options.query - A function that takes a {@link Realm.Collection} and returns a {@link Realm.Collection} of the same type. This allows for filtering and sorting of the collection, before it is returned. + * @param options.keyPaths - Indicates a lower bound on the changes relevant for the hook. This is a lower bound, since if multiple hooks add listeners (each with their own `keyPaths`) the union of these key-paths will determine the changes that are considered relevant for all listeners registered on the collection. In other words: A listener might fire and cause a re-render more than the key-paths specify, if other listeners with different key-paths are present. * @param deps - An array of dependencies that will be passed to {@link React.useMemo} * @returns a collection of realm objects or an empty array */ @@ -206,6 +233,7 @@ export const useQuery = defaultContext.useQuery; * ``` * @param type - The object type, depicted by a string or a class extending {@link Realm.Object} * @param primaryKey - The primary key of the desired object which will be retrieved using {@link Realm.objectForPrimaryKey} + * @param keyPaths - Indicates a lower bound on the changes relevant for the hook. This is a lower bound, since if multiple hooks add listeners (each with their own `keyPaths`) the union of these key-paths will determine the changes that are considered relevant for all listeners registered on the object. In other words: A listener might fire and cause a re-render more than the key-paths specify, if other listeners with different key-paths are present. * @returns either the desired {@link Realm.Object} or `null` in the case of it being deleted or not existing. */ export const useObject = defaultContext.useObject; diff --git a/packages/realm-react/src/useObject.tsx b/packages/realm-react/src/useObject.tsx index a9f4df640a..0cc323c487 100644 --- a/packages/realm-react/src/useObject.tsx +++ b/packages/realm-react/src/useObject.tsx @@ -20,19 +20,48 @@ import { useEffect, useMemo, useReducer, useRef } from "react"; import Realm from "realm"; import { CachedObject, createCachedObject } from "./cachedObject"; -import { CollectionCallback, getObjectForPrimaryKey, getObjects } from "./helpers"; +import { + AnyRealmObject, + CollectionCallback, + getObjectForPrimaryKey, + getObjects, + isClassModelConstructor, +} from "./helpers"; +import { UseRealmHook } from "./useRealm"; + +/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ +type RealmClassType = { new (...args: any): T }; + +export type ObjectHookOptions = { + type: string; + primaryKey: T[keyof T]; + keyPaths?: string | string[]; +}; + +export type ObjectHookClassBasedOptions = { + type: RealmClassType; + primaryKey: T[keyof T]; + keyPaths?: string | string[]; +}; + +export type UseObjectHook = { + (options: ObjectHookOptions): (T & Realm.Object) | null; + (options: ObjectHookClassBasedOptions): T | null; + + (type: string, primaryKey: T[keyof T], keyPaths?: string | string[]): (T & Realm.Object) | null; + (type: RealmClassType, primaryKey: T[keyof T], keyPaths?: string | string[]): T | null; +}; /** * Generates the `useObject` hook from a given `useRealm` hook. * @param useRealm - Hook that returns an open Realm instance * @returns useObject - Hook that is used to gain access to a single Realm object from a primary key */ -export function createUseObject(useRealm: () => Realm) { - function useObject(type: string, primaryKey: T[keyof T]): (T & Realm.Object) | null; - function useObject>(type: { new (...args: any): T }, primaryKey: T[keyof T]): T | null; - function useObject( - type: string | { new (...args: any): T }, +export function createUseObject(useRealm: UseRealmHook): UseObjectHook { + function useObject( + type: string | RealmClassType, primaryKey: T[keyof T], + keyPaths?: string | string[], ): T | null { const realm = useRealm(); @@ -58,12 +87,19 @@ export function createUseObject(useRealm: () => Realm) { // Ref: https://github.com/facebook/react/issues/14490 const cachedObjectRef = useRef(null); + const memoizedKeyPaths = useMemo( + () => (typeof keyPaths === "string" ? [keyPaths] : keyPaths), + /* eslint-disable-next-line react-hooks/exhaustive-deps -- Memoizing the keyPaths to avoid renders */ + [JSON.stringify(keyPaths)], + ); + if (!cachedObjectRef.current) { cachedObjectRef.current = createCachedObject({ object: originalObject ?? null, realm, updateCallback: forceRerender, updatedRef, + keyPaths: memoizedKeyPaths, }); } @@ -90,6 +126,7 @@ export function createUseObject(useRealm: () => Realm) { realm, updateCallback: forceRerender, updatedRef, + keyPaths: memoizedKeyPaths, }); originalObjectRef.current = originalObject; @@ -100,7 +137,7 @@ export function createUseObject(useRealm: () => Realm) { } return cachedObjectRef.current; }, - [realm, originalObject, primaryKey], + [realm, originalObject, primaryKey, memoizedKeyPaths], ); // Invoke the tearDown of the cachedObject when useObject is unmounted @@ -153,7 +190,23 @@ export function createUseObject(useRealm: () => Realm) { return objectRef.current as T; } - return useObject; + return function useObjectOverload( + typeOrOptions: string | RealmClassType | ObjectHookOptions | ObjectHookClassBasedOptions, + primaryKey?: T[keyof T], + keyPaths?: string | string[], + ): T | null { + if (typeof typeOrOptions === "string" || isClassModelConstructor(typeOrOptions)) { + if (typeof primaryKey === "undefined") { + throw new Error("Expected a primary key"); + } + /* eslint-disable-next-line react-hooks/rules-of-hooks -- We're calling `useQuery` once in any of the brances */ + return useObject(typeOrOptions, primaryKey, keyPaths); + } else { + const { type, primaryKey, keyPaths } = typeOrOptions; + /* eslint-disable-next-line react-hooks/rules-of-hooks -- We're calling `useQuery` once in any of the brances */ + return useObject(type, primaryKey, keyPaths); + } + }; } // This is a helper function that determines if two primary keys are equal. It will also handle the case where the primary key is an ObjectId or UUID diff --git a/packages/realm-react/src/useQuery.tsx b/packages/realm-react/src/useQuery.tsx index dd922e2d73..cfeb629f9c 100644 --- a/packages/realm-react/src/useQuery.tsx +++ b/packages/realm-react/src/useQuery.tsx @@ -20,31 +20,54 @@ import { useCallback, useEffect, useMemo, useReducer, useRef } from "react"; import Realm from "realm"; import { createCachedCollection } from "./cachedCollection"; -import { getObjects } from "./helpers"; +import { AnyRealmObject, RealmClassType, getObjects, isClassModelConstructor } from "./helpers"; -type RealmClassType = { new (...args: any): T }; type QueryCallback = (collection: Realm.Results) => Realm.Results; type DependencyList = ReadonlyArray; +export type QueryHookOptions = { + type: string; + query?: QueryCallback; + keyPaths?: string | string[]; +}; + +export type QueryHookClassBasedOptions = { + type: RealmClassType; + query?: QueryCallback; + keyPaths?: string | string[]; +}; + +export type UseQueryHook = { + (options: QueryHookOptions, deps?: DependencyList): Realm.Results>; + (options: QueryHookClassBasedOptions, deps?: DependencyList): Realm.Results; + (type: string): Realm.Results>; + (type: RealmClassType): Realm.Results; + + /** @deprecated To help the `react-hooks/exhaustive-deps` eslint rule detect missing dependencies, we've suggest passing a option object as the first argument */ + (type: string, query?: QueryCallback, deps?: DependencyList): Realm.Results>; + /** @deprecated To help the `react-hooks/exhaustive-deps` eslint rule detect missing dependencies, we've suggest passing a option object as the first argument */ + ( + type: RealmClassType, + query?: QueryCallback, + deps?: DependencyList, + ): Realm.Results; +}; + +/** + * Maps a value to itself + */ +function identity(value: T): T { + return value; +} + /** * Generates the `useQuery` hook from a given `useRealm` hook. * @param useRealm - Hook that returns an open Realm instance * @returns useObject - Hook that is used to gain access to a {@link Realm.Collection} */ -export function createUseQuery(useRealm: () => Realm) { - function useQuery( - type: string, - query?: QueryCallback, - deps?: DependencyList, - ): Realm.Results>; - function useQuery>( - type: RealmClassType, - query?: QueryCallback, - deps?: DependencyList, - ): Realm.Results; - function useQuery>( - type: string | RealmClassType, - query: QueryCallback = (collection: Realm.Results) => collection, +export function createUseQuery(useRealm: () => Realm): UseQueryHook { + function useQuery( + { type, query = identity, keyPaths }: QueryHookOptions | QueryHookClassBasedOptions, deps: DependencyList = [], ): Realm.Results { const realm = useRealm(); @@ -60,9 +83,9 @@ export function createUseQuery(useRealm: () => Realm) { const updatedRef = useRef(true); const queryCallbackRef = useRef | null>(null); - // We want the user of this hook to be able pass in the `query` function inline (without the need to `useCallback` on it) - // This means that the query function is unstable and will be a redefined on each render of the component where `useQuery` is used - // Therefore we use the `deps` array to memoize the query function internally, and only use the returned `queryCallback` + /* eslint-disable-next-line react-hooks/exhaustive-deps -- We want the user of this hook to be able pass in the `query` function inline (without the need to `useCallback` on it) + This means that the query function is unstable and will be a redefined on each render of the component where `useQuery` is used + Therefore we use the `deps` array to memoize the query function internally, and only use the returned `queryCallback` */ const queryCallback = useCallback(query, [...deps, ...requiredDeps]); // If the query function changes, we need to update the cachedCollection @@ -75,6 +98,12 @@ export function createUseQuery(useRealm: () => Realm) { return queryCallback(getObjects(realm, type)); }, [type, realm, queryCallback]); + const memoizedKeyPaths = useMemo( + () => (typeof keyPaths === "string" ? [keyPaths] : keyPaths), + /* eslint-disable-next-line react-hooks/exhaustive-deps -- Memoizing the keyPaths to avoid renders */ + [JSON.stringify(keyPaths)], + ); + // Wrap the cachedObject in useMemo, so we only replace it with a new instance if `realm` or `queryResult` change const { collection, tearDown } = useMemo(() => { return createCachedCollection({ @@ -82,8 +111,9 @@ export function createUseQuery(useRealm: () => Realm) { realm, updateCallback: forceRerender, updatedRef, + keyPaths: memoizedKeyPaths, }); - }, [realm, queryResult]); + }, [realm, queryResult, memoizedKeyPaths]); // Invoke the tearDown of the cachedCollection when useQuery is unmounted useEffect(() => { @@ -100,5 +130,23 @@ export function createUseQuery(useRealm: () => Realm) { // This will never not be defined, but the type system doesn't know that return collectionRef.current as Realm.Results; } - return useQuery; + + return function useQueryOverload( + typeOrOptions: QueryHookOptions | QueryHookClassBasedOptions | string | RealmClassType, + queryOrDeps: DependencyList | QueryCallback = identity, + deps: DependencyList = [], + ): Realm.Results { + if (typeof typeOrOptions === "string" && typeof queryOrDeps === "function") { + /* eslint-disable-next-line react-hooks/rules-of-hooks -- We're calling `useQuery` once in any of the brances */ + return useQuery({ type: typeOrOptions, query: queryOrDeps }, deps); + } else if (isClassModelConstructor(typeOrOptions) && typeof queryOrDeps === "function") { + /* eslint-disable-next-line react-hooks/rules-of-hooks -- We're calling `useQuery` once in any of the brances */ + return useQuery({ type: typeOrOptions as RealmClassType, query: queryOrDeps }, deps); + } else if (typeof typeOrOptions === "object" && typeOrOptions !== null) { + /* eslint-disable-next-line react-hooks/rules-of-hooks -- We're calling `useQuery` once in any of the brances */ + return useQuery(typeOrOptions, Array.isArray(queryOrDeps) ? queryOrDeps : deps); + } else { + throw new Error("Unexpected arguments passed to useQuery"); + } + }; } diff --git a/packages/realm-react/src/useRealm.tsx b/packages/realm-react/src/useRealm.tsx index 3ee638ea2b..596c9d60ef 100644 --- a/packages/realm-react/src/useRealm.tsx +++ b/packages/realm-react/src/useRealm.tsx @@ -19,14 +19,18 @@ import Realm from "realm"; import { useContext } from "react"; +export type UseRealmHook = { + (): Realm; +}; + /** * Generates a `useRealm` hook given a RealmContext. This allows access to the {@link Realm} * instance anywhere within the RealmProvider. * @param RealmContext - The context containing the {@link Realm} instance * @returns useRealm - Hook that is used to gain access to the {@link Realm} instance */ -export const createUseRealm = (RealmContext: React.Context) => { - return function useRealm(): Realm { +export const createUseRealm = (RealmContext: React.Context): UseRealmHook => { + return function useRealm() { // This is the context setup by `createRealmContext` const context = useContext(RealmContext); if (context === null) {