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 21 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
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 };
}
113 changes: 78 additions & 35 deletions packages/realm-react/src/__tests__/useObjectHook.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,81 +16,124 @@
//
////////////////////////////////////////////////////////////////////////////

import { useEffect, useState } from "react";
import Realm from "realm";
import { renderHook } from "@testing-library/react-native";

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 { result } = renderHook(() => useObject<IDog>("dog", dog2._id));

const [, river] = testDataSet;
const { result } = renderHook(() => useObject<IDog>("dog", river._id));
const object = result.current;

expect(object).toMatchObject(dog2);
expect(object).toMatchObject(river);
});

it("object is null", () => {
const { result } = renderHook(() => useObject<IDog>("dog", 12));
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");
});
});

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 except a re-render
kraenhansen marked this conversation as resolved.
Show resolved Hide resolved
write(() => {
if (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(() => {
if (result.current) {
result.current.age = 5;
}
});
expect(renders).toHaveLength(2);
Copy link
Member

@elle-j elle-j Jan 31, 2024

Choose a reason for hiding this comment

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

If result.current is falsy (and we thereby don't update the age), then the test may still pass since there is no new rerender. You do check the result.current in the expect before write(), so perhaps that's enough, but we could e.g. add an else clause in the callback and throw an error to make sure we test that the update doesn't cause a rerender in this case. (There's one more occurrence of this I believe.)

Copy link
Member Author

Choose a reason for hiding this comment

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

Good catch, this should probably just assert result.current instead 👍

});

expect(object).toEqual(null);
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 except a re-render
kraenhansen marked this conversation as resolved.
Show resolved Hide resolved
write(() => {
if (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(() => {
if (result.current) {
result.current.name = "Vince!";
}
});
expect(renders).toHaveLength(2);
});
});
});
3 changes: 2 additions & 1 deletion packages/realm-react/src/__tests__/useObjectRender.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { FlatList, ListRenderItem, Text, TextInput, TouchableHighlight, View } f
import Realm from "realm";

import { createUseObject } from "../useObject";
import { randomRealmPath } from "./helpers";

export class ListItem extends Realm.Object {
id!: Realm.BSON.ObjectId;
Expand Down Expand Up @@ -66,7 +67,7 @@ export class List extends Realm.Object {
const configuration: Realm.Configuration = {
schema: [List, ListItem],
deleteRealmIfMigrationNeeded: true,
path: "testArtifacts/use-object-render.realm",
path: randomRealmPath(),
};

// TODO: It would be better not to have to rely on this, but at the moment I see no other alternatives
Expand Down