diff --git a/packages/realm-react/CHANGELOG.md b/packages/realm-react/CHANGELOG.md index f0e663638e..a30c3bf55c 100644 --- a/packages/realm-react/CHANGELOG.md +++ b/packages/realm-react/CHANGELOG.md @@ -4,13 +4,29 @@ * None ### Enhancements -* None +* Added the ability to get `progress` information in `fallback` component of `RealmProvider` when opening a synced Realm. ([#6785](https://github.com/realm/realm-js/issues/6785)) +```tsx +import { RealmProvider, RealmProviderFallback } from "@realm/react"; + +const Fallback: RealmProviderFallback = ({ progress }) => { + return Loading:{(100 * progress).toFixed()}%; +} + +const MyApp() = () => { + return ( + + ... + + ); +} +``` ### Fixed * ([#????](https://github.com/realm/realm-js/issues/????), since v?.?.?) * None ### Compatibility +* Realm JavaScript >= v12.12.0 * React Native >= v0.71.4 * Realm Studio v15.0.0. * File format: generates Realms with format v24 (reads and upgrades file format v10). diff --git a/packages/realm-react/package.json b/packages/realm-react/package.json index 5f6f2eba49..305824956c 100644 --- a/packages/realm-react/package.json +++ b/packages/realm-react/package.json @@ -70,7 +70,7 @@ }, "peerDependencies": { "react": ">=17.0.2", - "realm": "^12.0.0-browser || ^12.0.0 || ^12.0.0-rc || ^11.0.0" + "realm": ">=12.12.0" }, "optionalDependencies": { "@babel/runtime": ">=7", diff --git a/packages/realm-react/src/RealmProvider.tsx b/packages/realm-react/src/RealmProvider.tsx index f70e6d6198..29162c1be7 100644 --- a/packages/realm-react/src/RealmProvider.tsx +++ b/packages/realm-react/src/RealmProvider.tsx @@ -27,6 +27,10 @@ type PartialRealmConfiguration = Omit, "sync"> & { sync?: Partial; }; +export type RealmProviderFallback = React.ComponentType<{ + progress: number; +}>; + /** Props used for a configuration-based Realm provider */ type RealmProviderConfigurationProps = { /** @@ -42,7 +46,7 @@ type RealmProviderConfigurationProps = { /** * The fallback component to render if the Realm is not open. */ - fallback?: React.ComponentType | React.ReactElement | null | undefined; + fallback?: RealmProviderFallback | React.ComponentType | React.ReactElement | null | undefined; children: React.ReactNode; } & PartialRealmConfiguration; @@ -158,6 +162,8 @@ export function createRealmProviderFromConfig( } }, [realm]); + const [progress, setProgress] = useState(0); + useEffect(() => { const realmRef = currentRealm.current; // Check if we currently have an open Realm. If we do not (i.e. it is the first @@ -165,7 +171,9 @@ export function createRealmProviderFromConfig( // need to open a new Realm. const shouldInitRealm = realmRef === null; const initRealm = async () => { - const openRealm = await Realm.open(configuration.current); + const openRealm = await Realm.open(configuration.current).progress((estimate: number) => { + setProgress(estimate); + }); setRealm(openRealm); }; if (shouldInitRealm) { @@ -184,7 +192,7 @@ export function createRealmProviderFromConfig( if (!realm) { if (typeof Fallback === "function") { - return ; + return ; } return <>{Fallback}; } diff --git a/packages/realm-react/src/__tests__/RealmProvider.test.tsx b/packages/realm-react/src/__tests__/RealmProvider.test.tsx index f43112ab58..d6cc4d94f9 100644 --- a/packages/realm-react/src/__tests__/RealmProvider.test.tsx +++ b/packages/realm-react/src/__tests__/RealmProvider.test.tsx @@ -22,9 +22,15 @@ import { Button, Text, View } from "react-native"; import { act, fireEvent, render, renderHook, waitFor } from "@testing-library/react-native"; import { RealmProvider, createRealmContext } from ".."; -import { RealmProviderFromRealm, areConfigurationsIdentical, mergeRealmConfiguration } from "../RealmProvider"; +import { + RealmProviderFallback, + RealmProviderFromRealm, + areConfigurationsIdentical, + mergeRealmConfiguration, +} from "../RealmProvider"; import { randomRealmPath } from "./helpers"; import { RealmContext } from "../RealmContext"; +import { MockedProgressRealmPromiseWithDelay, mockRealmOpen } from "./mocks"; const dogSchema: Realm.ObjectSchema = { name: "dog", @@ -231,11 +237,16 @@ describe("RealmProvider", () => { // TODO: Now that local realm is immediately set, the fallback never renders. // We need to test synced realm in order to produce the fallback - describe.skip("initially renders a fallback, until realm exists", () => { + describe("initially renders a fallback, until realm exists", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + it("as a component", async () => { + const slowRealmOpen = mockRealmOpen(); const App = () => { return ( - }> + }> ); @@ -245,17 +256,19 @@ describe("RealmProvider", () => { expect(queryByTestId("fallbackContainer")).not.toBeNull(); expect(queryByTestId("testContainer")).toBeNull(); - await waitFor(() => queryByTestId("testContainer")); + await act(async () => await slowRealmOpen); expect(queryByTestId("fallbackContainer")).toBeNull(); expect(queryByTestId("testContainer")).not.toBeNull(); }); it("as an element", async () => { + const slowRealmOpen = mockRealmOpen(); + const Fallback = ; const App = () => { return ( - + ); @@ -265,11 +278,43 @@ describe("RealmProvider", () => { expect(queryByTestId("fallbackContainer")).not.toBeNull(); expect(queryByTestId("testContainer")).toBeNull(); - await waitFor(() => queryByTestId("testContainer")); + await act(async () => await slowRealmOpen); expect(queryByTestId("fallbackContainer")).toBeNull(); expect(queryByTestId("testContainer")).not.toBeNull(); }); + + it("should receive progress information", async () => { + const expectedProgressValues = [0, 0.25, 0.5, 0.75, 1]; + const slowRealmOpen = mockRealmOpen( + new MockedProgressRealmPromiseWithDelay({ progressValues: expectedProgressValues }), + ); + const renderedProgressValues: number[] = []; + + const Fallback: RealmProviderFallback = ({ progress }) => { + renderedProgressValues.push(progress); + return {progress}; + }; + const App = () => { + return ( + + + + ); + }; + const { queryByTestId } = render(); + + expect(queryByTestId("fallbackContainer")).not.toBeNull(); + expect(queryByTestId("testContainer")).toBeNull(); + expect(renderedProgressValues).toStrictEqual([expectedProgressValues[0]]); + + await act(async () => await slowRealmOpen); + + expect(queryByTestId("fallbackContainer")).toBeNull(); + expect(queryByTestId("testContainer")).not.toBeNull(); + + expect(renderedProgressValues).toStrictEqual(expectedProgressValues); + }); }); }); diff --git a/packages/realm-react/src/__tests__/mocks.ts b/packages/realm-react/src/__tests__/mocks.ts new file mode 100644 index 0000000000..ad15cb17e0 --- /dev/null +++ b/packages/realm-react/src/__tests__/mocks.ts @@ -0,0 +1,144 @@ +//////////////////////////////////////////////////////////////////////////// +// +// 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 { act } from "@testing-library/react-native"; +import { EstimateProgressNotificationCallback, ProgressRealmPromise, Realm } from "realm"; +import { sleep } from "../helpers"; + +/** + * Mocks {@link Realm.ProgressRealmPromise} with a custom + * promise completion and progress handler. + */ +export class MockedProgressRealmPromise extends Promise implements ProgressRealmPromise { + private progressHandler?: (callback: EstimateProgressNotificationCallback) => void; + private cancelHandler?: () => void; + private realmPromise!: Promise; + + constructor( + getRealm: () => Promise, + options?: { + progress?: (callback: EstimateProgressNotificationCallback) => void; + cancel?: () => void; + }, + ) { + let realmPromise: Promise; + super((resolve) => { + realmPromise = getRealm(); + realmPromise.then((realm) => resolve(realm)); + }); + // @ts-expect-error realmPromise value will be assigned right away + this.realmPromise = realmPromise; + this.progressHandler = options?.progress; + this.cancelHandler = options?.cancel; + } + + get [Symbol.toStringTag]() { + return "MockedProgressRealmPromise"; + } + + cancel = () => { + if (!this.cancelHandler) { + throw new Error("cancel handler not set"); + } + this.cancelHandler(); + }; + + then( + onfulfilled?: ((value: Realm) => TResult1 | PromiseLike) | null | undefined, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null | undefined, + ): Promise { + return this.realmPromise.then(onfulfilled, onrejected); + } + + progress = (callback: EstimateProgressNotificationCallback) => { + if (!this.progressHandler) { + throw new Error("progress handler not set"); + } + this.progressHandler(callback); + return this; + }; +} + +/** + * Mocked {@link ProgressRealmPromise} which resolves after a set delay. + * If `options.progressValues` is specified, passes it through an + * equal interval to `Realm.open(...).progress(...)` callback. + */ +export class MockedProgressRealmPromiseWithDelay extends MockedProgressRealmPromise { + public currentProgressIndex = 0; + public progressValues: number[] | undefined; + private progressTimeout: NodeJS.Timeout | undefined; + + constructor( + options: { + delay?: number; + /** Progress values which the `Realm.open(...).progress(...)` will receive in an equal interval. */ + progressValues?: number[]; + } = {}, + ) { + const { progressValues, delay = 100 } = options; + super( + async () => { + await sleep(delay); + return new Realm(); + }, + { + progress: (callback) => { + this.progressTimeout = callMockedProgressNotifications(callback, delay, progressValues); + }, + cancel: () => clearTimeout(this.progressTimeout), + }, + ); + this.progressValues = progressValues; + } +} + +/** Calls given callbacks with progressValues in an equal interval */ +export function callMockedProgressNotifications( + callback: EstimateProgressNotificationCallback, + timeFrame: number, + progressValues: number[] = [0, 0.25, 0.5, 0.75, 1], +): NodeJS.Timeout { + let progressIndex = 0; + let progressInterval: NodeJS.Timeout | undefined = undefined; + const sendProgress = () => { + // Uses act as this causes a component state update. + act(() => callback(progressValues[progressIndex])); + progressIndex++; + + if (progressIndex >= progressValues.length) { + // Send the next progress update in equidistant time + clearInterval(progressInterval); + } + }; + progressInterval = setInterval(sendProgress, timeFrame / (progressValues.length + 1)); + sendProgress(); + return progressInterval; +} + +/** + * Mocks the Realm.open operation with a delayed, predictable Realm creation. + * @returns Promise which resolves when the Realm is opened. + */ +export function mockRealmOpen( + progressRealmPromise: MockedProgressRealmPromise = new MockedProgressRealmPromiseWithDelay(), +): MockedProgressRealmPromise { + const delayedRealmOpen = jest.spyOn(Realm, "open"); + delayedRealmOpen.mockImplementation(() => progressRealmPromise); + return progressRealmPromise; +} diff --git a/packages/realm-react/src/helpers.ts b/packages/realm-react/src/helpers.ts index ed6c2d8d24..8158443349 100644 --- a/packages/realm-react/src/helpers.ts +++ b/packages/realm-react/src/helpers.ts @@ -58,3 +58,12 @@ export type RestrictivePick = Pick & { [RestrictedKe export function isClassModelConstructor(value: unknown): value is RealmClassType { return Object.getPrototypeOf(value) === Realm.Object; } + +/** + * Adapted from integration-tests + * @param ms For how long should the promise be pending? + * @returns A promise that returns after `ms` milliseconds. + */ +export function sleep(ms = 1000): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +}