diff --git a/packages/realm-react/CHANGELOG.md b/packages/realm-react/CHANGELOG.md index 2725b2d5be..1dc59369ab 100644 --- a/packages/realm-react/CHANGELOG.md +++ b/packages/realm-react/CHANGELOG.md @@ -4,7 +4,39 @@ * None ### Enhancements -* None +* Added the ability to use an existing Realm instance in `RealmProvider` and `createRealmContext`. ([#6714](https://github.com/realm/realm-js/pull/6714)) +```jsx +// Using RealmProvider +import { RealmProvider } from "@realm/react"; + +const realm = new Realm(...); + +function MyApp() { + return ( + + ... + + ); +} + +// Using createRealmContext +import { createRealmContext } from "@realm/react"; + +const realm = new Realm(...); +const { RealmProvider, useRealm } = createRealmContext(realm); + +function MyApp() { + return ( + <> + + ... + + + {/* Note: The hooks returned from `createRealmContext` using an existing Realm can be used outside of the scope of the provider! */} + + + ); +``` ### Fixed * ([#????](https://github.com/realm/realm-js/issues/????), since v?.?.?) diff --git a/packages/realm-react/src/RealmContext.ts b/packages/realm-react/src/RealmContext.ts new file mode 100644 index 0000000000..84c36b1140 --- /dev/null +++ b/packages/realm-react/src/RealmContext.ts @@ -0,0 +1,196 @@ +//////////////////////////////////////////////////////////////////////////// +// +// 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 { createUseObject } from "./useObject"; +import { createUseQuery } from "./useQuery"; +import { createUseRealm } from "./useRealm"; +import { + DynamicRealmProvider, + RealmProviderFromConfiguration, + RealmProviderFromRealm, + createRealmProvider, +} from "./RealmProvider"; +import { createContext } from "react"; +import Realm from "realm"; + +export type RealmContext = { + /** + * The Provider component that is required to wrap any component using + * the Realm hooks. + * @example + * ``` + * const AppRoot = () => { + * const syncConfig = { + * flexible: true, + * user: currentUser + * }; + * + * return ( + * + * + * + * ) + * } + * ``` + * @param props - The {@link Realm.Configuration} or {@link Realm} of the provider + * are set based on the options passed to `createRealmProvider`. When using a + * {@link Realm.Configuration}, individual config keys can be overridden when + * creating a `` by passing them as props. For example, to override + * the `path` config value, use a prop named `path` e.g., `path="newPath.realm"` an + * attribute of the same key. + */ + RealmProvider: RealmProvider; + /** + * Returns the instance of the {@link Realm} opened by the `RealmProvider`. + * @example + * ``` + * const realm = useRealm(); + * ``` + * @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. + * + * The result of this can be consumed directly by the `data` argument of any React Native + * VirtualizedList or FlatList. If the component used for the list's `renderItem` prop is {@link React.Memo}ized, + * then only the modified object will re-render. + * @example + * ```tsx + * // Return all collection items + * const collection = useQuery({ type: Object }); + * + * // Return all collection items sorted by name and filtered by 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 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 + */ + useQuery: ReturnType; + /** + * Returns a {@link Realm.Object} from a given type and value of primary key. + * The hook will update on any changes to the properties on the returned object + * and return null if it either doesn't exists or has been deleted. + * @example + * ``` + * const object = useObject(ObjectClass, objectId); + * ``` + * @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; +}; + +/** + * Creates Realm React hooks and Provider component for a given Realm configuration + * @example + * ``` + *class Task extends Realm.Object { + * ... + * + * static schema: ObjectSchema = { + * name: 'Task', + * primaryKey: '_id', + * properties: { + * ... + * }, + * }; + *} + * + *const {useRealm, useQuery, useObject, RealmProvider} = createRealmContext({schema: [Task]}); + * ``` + * @param realmConfig - {@link Realm.Configuration} used to open the Realm + * @returns An object containing a `RealmProvider` component, and `useRealm`, `useQuery` and `useObject` hooks + */ +export function createRealmContext(realmConfig: Realm.Configuration): RealmContext; +/** + * Creates Realm React hooks and Provider component for a given Realm instance. + * + * **Note:** the hooks returned from `createRealmContext` using an existing Realm can be used outside of the scope of the provider. + * @example + * ``` + * const realm = new Realm({ schema: [...] }); + * const {useRealm, useQuery, useObject, RealmProvider} = createRealmContext(realm); + * ``` + * @param realm - {@link Realm} instance + * @returns An object containing a `RealmProvider` component, and `useRealm`, `useQuery` and `useObject` hooks + */ +export function createRealmContext(realm: Realm): RealmContext; +/** + * Creates Realm React hooks and Provider component. + * @example + * ``` + * class Task extends Realm.Object { + * ... + * static schema: ObjectSchema = { + * name: 'Task', + * primaryKey: '_id', + * properties: { + * ... + * }, + * }; + * } + * const {useRealm, useQuery, useObject, RealmProvider} = createRealmContext(); + * ... + * + * ``` + * @example + * ``` + * const realm = await Realm.open({ path: "example.realm", schema: [Task] }); + * const {RealmProvider} = createRealmContext(); + * ... + * + * ``` + * @returns An object containing a `RealmProvider` component, and `useRealm`, `useQuery` and `useObject` hooks + */ +export function createRealmContext(): RealmContext; +export function createRealmContext( + realmOrConfig?: Realm | Realm.Configuration, +): RealmContext { + const RealmContext = createContext(realmOrConfig instanceof Realm ? realmOrConfig : null); + const RealmProvider = createRealmProvider(realmOrConfig, RealmContext); + + const useRealm = createUseRealm(RealmContext); + const useQuery = createUseQuery(useRealm); + const useObject = createUseObject(useRealm); + + return { + RealmProvider, + useRealm, + useQuery, + useObject, + }; +} diff --git a/packages/realm-react/src/RealmProvider.tsx b/packages/realm-react/src/RealmProvider.tsx index e789027afe..3f1d88f185 100644 --- a/packages/realm-react/src/RealmProvider.tsx +++ b/packages/realm-react/src/RealmProvider.tsx @@ -21,16 +21,14 @@ import Realm from "realm"; import isEqual from "lodash.isequal"; import { UserContext } from "./UserProvider"; +import { RestrictivePick } from "./helpers"; type PartialRealmConfiguration = Omit, "sync"> & { sync?: Partial; }; -type ProviderProps = PartialRealmConfiguration & { - /** - * The fallback component to render if the Realm is not opened. - */ - fallback?: React.ComponentType | React.ReactElement | null | undefined; +/** Props used for a configuration-based Realm provider */ +type RealmProviderConfigurationProps = { /** * If false, Realm will not be closed when the component unmounts. * @default true @@ -41,43 +39,77 @@ type ProviderProps = PartialRealmConfiguration & { * instance outside of a component that uses the Realm hooks. */ realmRef?: React.MutableRefObject; + /** + * The fallback component to render if the Realm is not open. + */ + fallback?: React.ComponentType | React.ReactElement | null | undefined; + children: React.ReactNode; +} & PartialRealmConfiguration; + +/** Props used for a Realm instance-based Realm provider */ +type RealmProviderRealmProps = { + /** + * The Realm instance to be used by the provider. + */ + realm: Realm; children: React.ReactNode; }; +type RealmProviderProps = RealmProviderConfigurationProps & RealmProviderRealmProps; + +/** + * Represents the provider returned from `createRealmContext` with a Realm instance i.e. `createRealmContext(new Realm(...))`. + * Omits "realm" as it gets set at creation and cannot be changed. + + * **Note:** the hooks returned from `createRealmContext` using an existing Realm can be used outside of the scope of the provider. + */ +export type RealmProviderFromRealm = React.FC>; + +/** + * Represents the provider returned from `createRealmContext` with a configuration, i.e. `createRealmContext({schema: [...]})`. + */ +export type RealmProviderFromConfiguration = React.FC; + +/** + * Represents properties of a {@link DynamicRealmProvider} where Realm instance props are set and Configuration props are disallowed. + */ +export type DynamicRealmProviderWithRealmProps = RestrictivePick; + +/** + * Represents properties of a {@link DynamicRealmProvider} where Realm configuration props are set and Realm instance props are disallowed. + */ +export type DynamicsRealmProviderWithConfigurationProps = RestrictivePick< + RealmProviderProps, + keyof RealmProviderConfigurationProps +>; + +/** + * Represents the provider returned from creating context with no arguments (including the default context). + * Supports either {@link RealmProviderRealmProps} or {@link RealmProviderConfigurationProps}. + */ +export type DynamicRealmProvider = React.FC< + DynamicRealmProviderWithRealmProps | DynamicsRealmProviderWithConfigurationProps +>; + +export function createRealmProviderFromRealm( + realm: Realm, + RealmContext: React.Context, +): RealmProviderFromRealm { + return ({ children }) => { + return ; + }; +} + /** * Generates a `RealmProvider` given a {@link Realm.Configuration} and {@link React.Context}. * @param realmConfig - The configuration of the Realm to be instantiated * @param RealmContext - The context that will contain the Realm instance * @returns a RealmProvider component that provides context to all context hooks */ -export function createRealmProvider( +export function createRealmProviderFromConfig( realmConfig: Realm.Configuration, RealmContext: React.Context, -): React.FC { - /** - * Returns a Context Provider component that is required to wrap any component using - * the Realm hooks. - * @example - * ``` - * const AppRoot = () => { - * const syncConfig = { - * flexible: true, - * user: currentUser - * }; - * - * return ( - * - * - * - * ) - * } - * ``` - * @param props - The {@link Realm.Configuration} for this Realm defaults to - * the config passed to `createRealmProvider`, but individual config keys can - * be overridden when creating a `` by passing them as props. - * For example, to override the `path` config value, use a prop named `path`, - * e.g. `path="newPath.realm"` - */ +): RealmProviderFromConfiguration { return ({ children, fallback: Fallback, closeOnUnmount = true, realmRef, ...restProps }) => { const [realm, setRealm] = useState(() => realmConfig.sync === undefined && restProps.sync === undefined @@ -161,6 +193,47 @@ export function createRealmProvider( }; } +/** + * Generates a `RealmProvider` which is either based on a configuration + * or based on a realm, depending on its props. + * @param RealmContext - The context that will contain the Realm instance + * @returns a RealmProvider component that provides context to all context hooks + */ +export function createDynamicRealmProvider(RealmContext: React.Context): DynamicRealmProvider { + return ({ realm, children, ...configurationProps }) => { + if (realm) { + if (Object.keys(configurationProps).length > 0) { + throw new Error("Cannot use configuration props when using an existing Realm instance."); + } + + const RealmProvider = createRealmProviderFromRealm(realm, RealmContext); + return {children}; + } else { + const RealmProvider = createRealmProviderFromConfig({}, RealmContext); + return {children}; + } + }; +} + +/** + * Generates the appropriate `RealmProvider` based on whether there is a config, realm, or neither given. + * @param realmOrConfig - A Realm instance, a configuration, or undefined (including default provider). + * @param RealmContext - The context that will contain the Realm instance + * @returns a RealmProvider component that provides context to all context hooks + */ +export function createRealmProvider( + realmOrConfig: Realm.Configuration | Realm | undefined, + RealmContext: React.Context, +): RealmProviderFromConfiguration | RealmProviderFromRealm | DynamicRealmProvider { + if (!realmOrConfig) { + return createDynamicRealmProvider(RealmContext); + } else if (realmOrConfig instanceof Realm) { + return createRealmProviderFromRealm(realmOrConfig, RealmContext); + } else { + return createRealmProviderFromConfig(realmOrConfig, RealmContext); + } +} + /** * Merge two configurations, creating a configuration using `configA` as the default, * merged with `configB`, with properties in `configB` overriding `configA`. diff --git a/packages/realm-react/src/__tests__/RealmProvider.test.tsx b/packages/realm-react/src/__tests__/RealmProvider.test.tsx index 63709d2d7c..f43112ab58 100644 --- a/packages/realm-react/src/__tests__/RealmProvider.test.tsx +++ b/packages/realm-react/src/__tests__/RealmProvider.test.tsx @@ -21,9 +21,10 @@ import Realm, { User } from "realm"; import { Button, Text, View } from "react-native"; import { act, fireEvent, render, renderHook, waitFor } from "@testing-library/react-native"; -import { createRealmContext } from ".."; -import { areConfigurationsIdentical, mergeRealmConfiguration } from "../RealmProvider"; +import { RealmProvider, createRealmContext } from ".."; +import { RealmProviderFromRealm, areConfigurationsIdentical, mergeRealmConfiguration } from "../RealmProvider"; import { randomRealmPath } from "./helpers"; +import { RealmContext } from "../RealmContext"; const dogSchema: Realm.ObjectSchema = { name: "dog", @@ -43,317 +44,578 @@ const catSchema: Realm.ObjectSchema = { }, }; -const { RealmProvider, useRealm } = createRealmContext({ +const withConfigRealmContext = createRealmContext({ schema: [dogSchema], inMemory: true, path: randomRealmPath(), }); -const EmptyRealmContext = createRealmContext(); - describe("RealmProvider", () => { afterEach(() => { Realm.clearTestState(); }); - it("returns the configured realm with useRealm and closes on unmount", async () => { - const wrapper = ({ children }: { children: React.ReactNode }) => {children}; - const { result, unmount } = renderHook(() => useRealm(), { wrapper }); - await waitFor(() => expect(result.current).not.toBe(null)); - const realm = result.current; - expect(realm).not.toBe(null); - expect(realm.schema[0].name).toBe("dog"); - unmount(); - expect(realm.isClosed).toBe(true); - }); + describe("with a Realm Configuration", () => { + const { RealmProvider, useRealm } = withConfigRealmContext; - it("returns the configured realm with useRealm and stays open if flagged", async () => { - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - const { result, unmount } = renderHook(() => useRealm(), { wrapper }); - await waitFor(() => expect(result.current).not.toBe(null)); - const realm = result.current; - expect(realm.schema[0].name).toBe("dog"); - unmount(); - expect(realm.isClosed).toBe(false); - }); + it("returns the configured realm with useRealm", async () => { + const wrapper = ({ children }: { children: React.ReactNode }) => {children}; + const { result } = renderHook(() => useRealm(), { wrapper }); + await waitFor(() => expect(result.current).not.toBe(null)); + const realm = result.current; + expect(realm).toBeInstanceOf(Realm); + expect(realm.schema[0].name).toBe("dog"); + }); - it("will override the the configuration provided in createRealmContext", async () => { - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - const { result } = renderHook(() => useRealm(), { wrapper }); - await waitFor(() => expect(result.current).not.toBe(null)); - const realm = result.current; - expect(realm).not.toBe(null); - expect(realm.schema[0].name).toBe("cat"); - }); - it("can be used with an initially empty realm context", async () => { - const wrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - const { result } = renderHook(() => EmptyRealmContext.useRealm(), { wrapper }); - await waitFor(() => expect(result.current).not.toBe(null)); - const realm = result.current; - expect(realm).not.toBe(null); - expect(realm.schema[0].name).toBe("cat"); - }); + it("closes realm on unmount by default", async () => { + const wrapper = ({ children }: { children: React.ReactNode }) => {children}; + const { result, unmount } = renderHook(() => useRealm(), { wrapper }); + await waitFor(() => expect(result.current).not.toBe(null)); + const realm = result.current; + unmount(); + expect(realm.isClosed).toBe(true); + }); - it("can be provided in multiple parts of an application", async () => { - const RealmComponent = () => { - const realm = useRealm(); - return ( -