Skip to content

Commit

Permalink
@realm/react key-path filtering on useQuery and useObject (#6360)
Browse files Browse the repository at this point in the history
* Seperated types from hook functions

* Adding a helper to generate a random realm path

* Use eslint-disable-next-line comment to disable a warning

* Adding a profileHook utility on-top-of renderHook

* Adding overloads to useQuery

* Adding failing tests

* Refactored tests into using a "realm test context"

* Passing key-paths through useQuery into the SDK's addListener method

* Reusing randomRealmPath

* Made useObject tests use createRealmTestContext

* adding a useObject test for re-render on object creation

* Implement keyPaths on useObject

* Incorporating feedback

* Allow passing a string as "keyPaths"

* Exporting types

* Adding @overload doc comments

* Focusing docs on the desired call-pattern

* Ran lint --fix

* Adding a AnyRealmObject utility type

* Using the `options` overload in the readme

* Adding a non-deprecated single argument overload

* Apply suggestions from code review

Co-authored-by: LJ <81748770+elle-j@users.noreply.github.com>

* Using asserts instead of if-statements

* Moved RealmClassType, AnyRealmObject and isClassModelConstructor into helpers

* Implemented options object overload on useObject hook

* Adding a note to the changelog

* Update packages/realm-react/CHANGELOG.md

Co-authored-by: Andrew Meyer <andrew.meyer@mongodb.com>

---------

Co-authored-by: LJ <81748770+elle-j@users.noreply.github.com>
Co-authored-by: Andrew Meyer <andrew.meyer@mongodb.com>
  • Loading branch information
3 people committed Feb 16, 2024
1 parent cb95630 commit b54d7f6
Show file tree
Hide file tree
Showing 17 changed files with 591 additions and 113 deletions.
13 changes: 11 additions & 2 deletions 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)!
Expand Down
11 changes: 7 additions & 4 deletions packages/realm-react/README.md
Expand Up @@ -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 (
Expand Down
3 changes: 2 additions & 1 deletion packages/realm-react/src/__tests__/RealmProvider.test.tsx
Expand Up @@ -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",
Expand All @@ -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();
Expand Down
68 changes: 68 additions & 0 deletions 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;
}
8 changes: 8 additions & 0 deletions packages/realm-react/src/__tests__/helpers.ts
Expand Up @@ -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";

Expand Down Expand Up @@ -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");
}
76 changes: 76 additions & 0 deletions 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<React.PropsWithChildren>;
renders: RenderEvent[];
};

function createProfilerWrapper(Parent: undefined | React.ComponentType<React.PropsWithChildren>): 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<React.PropsWithChildren> = ({ children }) => (
<Profiler id={id} onRender={handleRender} children={children} />
);

return {
wrapper: Parent
? ({ children }) => (
<Parent>
<Wrapper children={children} />
</Parent>
)
: Wrapper,
renders,
};
}

export function profileHook<Result, Props>(
callback: (props: Props) => Result,
options?: RenderHookOptions<Props>,
): RenderHookResult<Result, Props> & { renders: RenderEvent[] } {
const { wrapper, renders } = createProfilerWrapper(options?.wrapper);
const result = renderHook<Result, Props>(callback, { ...options, wrapper });
return { ...result, renders };
}
128 changes: 95 additions & 33 deletions packages/realm-react/src/__tests__/useObjectHook.test.tsx
Expand Up @@ -16,81 +16,143 @@
//
////////////////////////////////////////////////////////////////////////////

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",
primaryKey: "_id",
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<IDog>("dog", river._id));
const object = result.current;
expect(object).toMatchObject(river);
});

const { result } = renderHook(() => useObject<IDog>("dog", dog2._id));
describe("missing objects", () => {
it("return null", () => {
const { result } = renderHook(() => useObject<IDog>("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<IDog>("dog", 12));
expect(renders).toHaveLength(1);
expect(result.current).toEqual(null);
write(() => {
realm.create<IDog>("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<IDog>("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<IDog>("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<IDog>("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<IDog>({ 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);
});
});
});

0 comments on commit b54d7f6

Please sign in to comment.