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

@realm/react key-path filtering on useQuery and useObject #6360

Merged
merged 27 commits into from
Feb 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ed6558b
Seperated types from hook functions
kraenhansen Jan 8, 2024
1e96013
Adding a helper to generate a random realm path
kraenhansen Jan 9, 2024
74fdcac
Use eslint-disable-next-line comment to disable a warning
kraenhansen Jan 9, 2024
056df66
Adding a profileHook utility on-top-of renderHook
kraenhansen Jan 9, 2024
88f53c1
Adding overloads to useQuery
kraenhansen Jan 9, 2024
8330c9a
Adding failing tests
kraenhansen Jan 9, 2024
340aec4
Refactored tests into using a "realm test context"
kraenhansen Jan 9, 2024
e745dfb
Passing key-paths through useQuery into the SDK's addListener method
kraenhansen Jan 9, 2024
67b22af
Reusing randomRealmPath
kraenhansen Jan 9, 2024
b12282b
Made useObject tests use createRealmTestContext
kraenhansen Jan 9, 2024
995cf7d
adding a useObject test for re-render on object creation
kraenhansen Jan 9, 2024
307dc1b
Implement keyPaths on useObject
kraenhansen Jan 9, 2024
c0008ba
Incorporating feedback
kraenhansen Jan 29, 2024
181626d
Allow passing a string as "keyPaths"
kraenhansen Jan 29, 2024
0a8daef
Exporting types
kraenhansen Jan 29, 2024
fa91205
Adding @overload doc comments
kraenhansen Jan 29, 2024
7dd55c8
Focusing docs on the desired call-pattern
kraenhansen Jan 30, 2024
fc9fe8a
Ran lint --fix
kraenhansen Jan 30, 2024
a566a53
Adding a AnyRealmObject utility type
kraenhansen Jan 30, 2024
8f568f6
Using the `options` overload in the readme
kraenhansen Jan 30, 2024
804fa6d
Adding a non-deprecated single argument overload
kraenhansen Jan 30, 2024
2a165ca
Apply suggestions from code review
kraenhansen Jan 31, 2024
0f6eb61
Using asserts instead of if-statements
kraenhansen Jan 31, 2024
3483dc9
Moved RealmClassType, AnyRealmObject and isClassModelConstructor into…
kraenhansen Jan 31, 2024
110eaf8
Implemented options object overload on useObject hook
kraenhansen Jan 31, 2024
96bfc10
Adding a note to the changelog
kraenhansen Jan 31, 2024
ed9774e
Update packages/realm-react/CHANGELOG.md
kraenhansen Feb 16, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
13 changes: 11 additions & 2 deletions packages/realm-react/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Member Author

Choose a reason for hiding this comment

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

I've added this helper, to make it easier to work with Realms in the tests.

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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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";
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice usage of the Profiler here 👍🏼

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
Original file line number Diff line number Diff line change
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] });
elle-j marked this conversation as resolved.
Show resolved Hide resolved
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);
});
});
});