Skip to content

Commit

Permalink
RJS-2187: Add progress information to RealmProvider fallback. (#6801)
Browse files Browse the repository at this point in the history
  • Loading branch information
gagik authored Aug 8, 2024
1 parent 9364039 commit 9e2b720
Show file tree
Hide file tree
Showing 6 changed files with 233 additions and 11 deletions.
18 changes: 17 additions & 1 deletion packages/realm-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Text>Loading:{(100 * progress).toFixed()}%</Text>;
}

const MyApp() = () => {
return (
<RealmProvider sync={...} fallback={Fallback}>
...
</RealmProvider>
);
}
```

### Fixed
* <How to hit and notice issue? what was the impact?> ([#????](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).
Expand Down
2 changes: 1 addition & 1 deletion packages/realm-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 11 additions & 3 deletions packages/realm-react/src/RealmProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ type PartialRealmConfiguration = Omit<Partial<Realm.Configuration>, "sync"> & {
sync?: Partial<Realm.SyncConfiguration>;
};

export type RealmProviderFallback = React.ComponentType<{
progress: number;
}>;

/** Props used for a configuration-based Realm provider */
type RealmProviderConfigurationProps = {
/**
Expand All @@ -42,7 +46,7 @@ type RealmProviderConfigurationProps = {
/**
* The fallback component to render if the Realm is not open.
*/
fallback?: React.ComponentType<unknown> | React.ReactElement | null | undefined;
fallback?: RealmProviderFallback | React.ComponentType | React.ReactElement | null | undefined;
children: React.ReactNode;
} & PartialRealmConfiguration;

Expand Down Expand Up @@ -158,14 +162,18 @@ export function createRealmProviderFromConfig(
}
}, [realm]);

const [progress, setProgress] = useState<number>(0);

useEffect(() => {
const realmRef = currentRealm.current;
// Check if we currently have an open Realm. If we do not (i.e. it is the first
// render, or the Realm has been closed due to a config change), then we
// 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) {
Expand All @@ -184,7 +192,7 @@ export function createRealmProviderFromConfig(

if (!realm) {
if (typeof Fallback === "function") {
return <Fallback />;
return <Fallback progress={progress} />;
}
return <>{Fallback}</>;
}
Expand Down
57 changes: 51 additions & 6 deletions packages/realm-react/src/__tests__/RealmProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 (
<RealmProvider fallback={() => <View testID="fallbackContainer" />}>
<RealmProvider sync={{}} fallback={() => <View testID="fallbackContainer" />}>
<View testID="testContainer" />
</RealmProvider>
);
Expand All @@ -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 = <View testID="fallbackContainer" />;
const App = () => {
return (
<RealmProvider fallback={Fallback}>
<RealmProvider sync={{}} fallback={Fallback}>
<View testID="testContainer" />
</RealmProvider>
);
Expand All @@ -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 <View testID="fallbackContainer">{progress}</View>;
};
const App = () => {
return (
<RealmProvider sync={{}} fallback={Fallback}>
<View testID="testContainer" />
</RealmProvider>
);
};
const { queryByTestId } = render(<App />);

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);
});
});
});

Expand Down
144 changes: 144 additions & 0 deletions packages/realm-react/src/__tests__/mocks.ts
Original file line number Diff line number Diff line change
@@ -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<Realm> implements ProgressRealmPromise {
private progressHandler?: (callback: EstimateProgressNotificationCallback) => void;
private cancelHandler?: () => void;
private realmPromise!: Promise<Realm>;

constructor(
getRealm: () => Promise<Realm>,
options?: {
progress?: (callback: EstimateProgressNotificationCallback) => void;
cancel?: () => void;
},
) {
let realmPromise: Promise<Realm>;
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<TResult1 = Realm, TResult2 = never>(
onfulfilled?: ((value: Realm) => TResult1 | PromiseLike<TResult1>) | null | undefined,
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null | undefined,
): Promise<TResult1 | TResult2> {
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;
}
9 changes: 9 additions & 0 deletions packages/realm-react/src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,12 @@ export type RestrictivePick<T, K extends keyof T> = Pick<T, K> & { [RestrictedKe
export function isClassModelConstructor(value: unknown): value is RealmClassType<unknown> {
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

0 comments on commit 9e2b720

Please sign in to comment.