Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RJS-2187: Add progress information to RealmProvider fallback. #6801

Merged
merged 9 commits into from
Aug 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
gagik marked this conversation as resolved.
Show resolved Hide resolved
* 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 {
gagik marked this conversation as resolved.
Show resolved Hide resolved
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");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(How) is this mock restored? Perhaps this side-effect could be moved into the test where you could also call jest.restoreAllMocks() to avoid this state bleeding into other tests.

Copy link
Contributor Author

@gagik gagik Jul 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have been running the cleaning but I agree it's not intuitive that this side effect exists. Maybe even extending this jest with like jest.mockRealmOpen() could be the most consistent with rest of jest mock API to have these mock helpers (and then intuitively jest.restoreAllMocks() cleans them).

Alternatively we wrap them in a hook which includes a clean.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like extending jest isn't the norm as it is with extending expect so I guess we either can define a hook with cleanup built in (although then if we have multiple mocks we'd be running the cleanup too much) or use the function as is and just make sure to run the restoreAllMocks

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