Skip to content

Commit be987c9

Browse files
authored
entities cache (#118)
1 parent 56cb99f commit be987c9

26 files changed

+1151
-267
lines changed

apps/events/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"lucide-react": "^0.471.1",
2828
"react": "^19.0.0",
2929
"react-dom": "^19.0.0",
30+
"react-select": "^5.10.0",
3031
"siwe": "^2.3.2",
3132
"tailwind-merge": "^2.6.0",
3233
"tailwindcss-animate": "^1.0.7",
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { useQueryEntities } from '@graphprotocol/hypergraph-react';
2+
import { Todo } from '../schema';
3+
4+
export const TodosReadOnly = () => {
5+
const todos = useQueryEntities(Todo);
6+
7+
return (
8+
<>
9+
<h1 className="text-2xl font-bold">Todos (read only)</h1>
10+
{todos.map((todo) => (
11+
<div key={todo.id} className="flex flex-row items-center gap-2">
12+
<h2>{todo.name}</h2>
13+
<input type="checkbox" checked={todo.completed} readOnly />
14+
</div>
15+
))}
16+
</>
17+
);
18+
};

apps/events/src/components/todos.tsx

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,41 @@
11
import { useCreateEntity, useDeleteEntity, useQueryEntities, useUpdateEntity } from '@graphprotocol/hypergraph-react';
2-
import { useState } from 'react';
3-
import { Todo } from '../schema';
2+
import { useEffect, useState } from 'react';
3+
import Select from 'react-select';
4+
import { Todo, User } from '../schema';
45
import { Button } from './ui/button';
56
import { Input } from './ui/input';
67

78
export const Todos = () => {
89
const todos = useQueryEntities(Todo);
10+
const users = useQueryEntities(User);
911
const createEntity = useCreateEntity(Todo);
1012
const updateEntity = useUpdateEntity(Todo);
1113
const deleteEntity = useDeleteEntity();
12-
const [newTodoTitle, setNewTodoTitle] = useState('');
14+
const [newTodoName, setNewTodoName] = useState('');
15+
const [assignees, setAssignees] = useState<{ value: string; label: string }[]>([]);
1316

17+
useEffect(() => {
18+
setAssignees((prevFilteredAssignees) => {
19+
// filter out assignees that are not in the users array whenever users change
20+
return prevFilteredAssignees.filter((assignee) => users.some((user) => user.id === assignee.value));
21+
});
22+
}, [users]);
23+
24+
const userOptions = users.map((user) => ({ value: user.id, label: user.name }));
1425
return (
1526
<>
1627
<h1 className="text-2xl font-bold">Todos</h1>
17-
<div className="flex flex-row gap-2">
18-
<Input type="text" value={newTodoTitle} onChange={(e) => setNewTodoTitle(e.target.value)} />
28+
<div className="flex flex-col gap-2">
29+
<Input type="text" value={newTodoName} onChange={(e) => setNewTodoName(e.target.value)} />
30+
<Select isMulti value={assignees} onChange={(e) => setAssignees(e.map((a) => a))} options={userOptions} />
1931
<Button
2032
onClick={() => {
21-
createEntity({ name: newTodoTitle, completed: false });
22-
setNewTodoTitle('');
33+
if (newTodoName === '') {
34+
alert('Todo text is required');
35+
return;
36+
}
37+
createEntity({ name: newTodoName, completed: false, assignees: assignees.map(({ value }) => value) });
38+
setNewTodoName('');
2339
}}
2440
>
2541
Create Todo
@@ -28,6 +44,27 @@ export const Todos = () => {
2844
{todos.map((todo) => (
2945
<div key={todo.id} className="flex flex-row items-center gap-2">
3046
<h2>{todo.name}</h2>
47+
{todo.assignees.length > 0 && (
48+
<span className="text-xs text-gray-500">
49+
Assigned to:{' '}
50+
{todo.assignees.map((assignee) => (
51+
<span key={assignee.id} className="border rounded-sm mr-1 p-1">
52+
{assignee.name}
53+
<button
54+
type="button"
55+
onClick={() =>
56+
updateEntity(todo.id, {
57+
assignees: todo.assignees.map((assignee) => assignee.id).filter((id) => id !== assignee.id),
58+
})
59+
}
60+
className="cursor-pointer ml-1 text-red-400"
61+
>
62+
x
63+
</button>
64+
</span>
65+
))}
66+
</span>
67+
)}
3168
<input
3269
type="checkbox"
3370
checked={todo.completed}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { useDeleteEntity, useUpdateEntity } from '@graphprotocol/hypergraph-react';
2+
import { useState } from 'react';
3+
import { User } from '../schema.js';
4+
import { Button } from './ui/button';
5+
import { Input } from './ui/input.js';
6+
7+
export const UserEntry = (user: User) => {
8+
const deleteEntity = useDeleteEntity();
9+
const updateEntity = useUpdateEntity(User);
10+
const [editMode, setEditMode] = useState(false);
11+
12+
return (
13+
<div key={user.id} className="flex flex-row items-center gap-2">
14+
<h2>
15+
{user.name} <span className="text-xs text-gray-500">({user.id})</span>
16+
</h2>
17+
<Button onClick={() => deleteEntity(user.id)}>Delete</Button>
18+
<Button onClick={() => setEditMode((prev) => !prev)}>Edit User</Button>
19+
20+
{editMode && (
21+
<Input type="text" value={user.name} onChange={(e) => updateEntity(user.id, { name: e.target.value })} />
22+
)}
23+
</div>
24+
);
25+
};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { useCreateEntity, useQueryEntities } from '@graphprotocol/hypergraph-react';
2+
import { useState } from 'react';
3+
import { User } from '../schema.js';
4+
import { Button } from './ui/button.js';
5+
import { Input } from './ui/input.js';
6+
import { UserEntry } from './user-entry.js';
7+
8+
export const Users = () => {
9+
const users = useQueryEntities(User);
10+
const createEntity = useCreateEntity(User);
11+
const [newUserName, setNewUserName] = useState('');
12+
13+
return (
14+
<>
15+
<h1 className="text-2xl font-bold">Users</h1>
16+
<div className="flex flex-row gap-2">
17+
<Input type="text" value={newUserName} onChange={(e) => setNewUserName(e.target.value)} />
18+
<Button
19+
onClick={() => {
20+
createEntity({ name: newUserName });
21+
setNewUserName('');
22+
}}
23+
>
24+
Create User
25+
</Button>
26+
</div>
27+
{users.map((user) => (
28+
<UserEntry key={user.id} {...user} />
29+
))}
30+
</>
31+
);
32+
};

apps/events/src/routes/space/$spaceId.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import { useSelector } from '@xstate/store/react';
55

66
import { DevTool } from '@/components/dev-tool';
77
import { Todos } from '@/components/todos';
8+
import { TodosReadOnly } from '@/components/todos-read-only';
89
import { Button } from '@/components/ui/button';
10+
import { Users } from '@/components/users';
911
import { availableAccounts } from '@/lib/availableAccounts';
10-
import { useEffect } from 'react';
12+
import { useEffect, useState } from 'react';
1113
import { getAddress } from 'viem';
1214

1315
export const Route = createFileRoute('/space/$spaceId')({
@@ -23,6 +25,7 @@ function Space() {
2325
subscribeToSpace({ spaceId });
2426
}
2527
}, [loading, subscribeToSpace, spaceId]);
28+
const [show2ndTodos, setShow2ndTodos] = useState(false);
2629

2730
const space = spaces.find((space) => space.id === spaceId);
2831

@@ -37,7 +40,10 @@ function Space() {
3740
return (
3841
<div className="flex flex-col gap-4 max-w-screen-sm mx-auto py-8">
3942
<HypergraphSpaceProvider space={spaceId}>
43+
<Users />
4044
<Todos />
45+
<TodosReadOnly />
46+
{show2ndTodos && <Todos />}
4147
<h3 className="text-xl font-bold">Invite people</h3>
4248
<div className="flex flex-row gap-2">
4349
{availableAccounts.map((invitee) => {
@@ -56,8 +62,9 @@ function Space() {
5662
);
5763
})}
5864
</div>
59-
<div className="mt-12">
65+
<div className="mt-12 flex flex-row gap-2">
6066
<DevTool spaceId={spaceId} />
67+
<Button onClick={() => setShow2ndTodos((prevShow2ndTodos) => !prevShow2ndTodos)}>Toggle Todos</Button>
6168
</div>
6269
</HypergraphSpaceProvider>
6370
</div>

apps/events/src/schema.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { Entity } from '@graphprotocol/hypergraph';
22

3+
export class User extends Entity.Class<User>('User')({
4+
id: Entity.Generated(Entity.Text),
5+
name: Entity.Text,
6+
}) {}
7+
38
export class Todo extends Entity.Class<Todo>('Todo')({
49
id: Entity.Generated(Entity.Text),
510
name: Entity.Text,
611
completed: Entity.Checkbox,
12+
assignees: Entity.Reference(Entity.ReferenceArray(User)),
713
}) {}

packages/hypergraph-react/src/HypergraphSpaceContext.tsx

Lines changed: 8 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { AnyDocumentId, DocHandle, Repo } from '@automerge/automerge-repo';
44
import { useRepo } from '@automerge/automerge-repo-react-hooks';
55
import { Entity, Utils } from '@graphprotocol/hypergraph';
66
import * as Schema from 'effect/Schema';
7-
import { type ReactNode, createContext, useContext, useRef, useSyncExternalStore } from 'react';
7+
import { type ReactNode, createContext, useContext, useRef, useState, useSyncExternalStore } from 'react';
88

99
export type HypergraphContext = {
1010
space: string;
@@ -31,11 +31,13 @@ export function HypergraphSpaceProvider({ space, children }: { space: string; ch
3131
let current = ref.current;
3232
if (current === undefined || space !== current.space || repo !== current.repo) {
3333
const id = Utils.idToAutomergeId(space) as AnyDocumentId;
34+
const handle = repo.find<Entity.DocumentContent>(id);
35+
3436
current = ref.current = {
3537
space,
3638
repo,
3739
id,
38-
handle: repo.find(id),
40+
handle,
3941
};
4042
}
4143

@@ -59,37 +61,11 @@ export function useDeleteEntity() {
5961

6062
export function useQueryEntities<const S extends Entity.AnyNoContext>(type: S) {
6163
const hypergraph = useHypergraph();
62-
const equal = isEqual(type);
63-
64-
// store as a map of type to array of entities of the type
65-
const prevEntitiesRef = useRef<Readonly<Array<Entity.Entity<S>>>>([]);
66-
67-
const subscribe = (callback: () => void) => {
68-
const handleChange = () => {
69-
callback();
70-
};
71-
72-
const handleDelete = () => {
73-
callback();
74-
};
75-
76-
hypergraph.handle.on('change', handleChange);
77-
hypergraph.handle.on('delete', handleDelete);
78-
79-
return () => {
80-
hypergraph.handle.off('change', handleChange);
81-
hypergraph.handle.off('delete', handleDelete);
82-
};
83-
};
84-
85-
return useSyncExternalStore<Readonly<Array<Entity.Entity<S>>>>(subscribe, () => {
86-
const filtered = Entity.findMany(hypergraph.handle, type);
87-
if (!equal(filtered, prevEntitiesRef.current)) {
88-
prevEntitiesRef.current = filtered;
89-
}
90-
91-
return prevEntitiesRef.current;
64+
const [subscription] = useState(() => {
65+
return Entity.subscribeToFindMany(hypergraph.handle, type);
9266
});
67+
68+
return useSyncExternalStore(subscription.subscribe, subscription.getEntities, () => []);
9369
}
9470

9571
export function useQueryEntity<const S extends Entity.AnyNoContext>(type: S, id: string) {
@@ -135,22 +111,3 @@ export function useQueryEntity<const S extends Entity.AnyNoContext>(type: S, id:
135111
return prevEntityRef.current;
136112
});
137113
}
138-
139-
/** @internal */
140-
const isEqual = <A, E>(type: Schema.Schema<A, E, never>) => {
141-
const equals = Schema.equivalence(type);
142-
143-
return (a: ReadonlyArray<A>, b: ReadonlyArray<A>) => {
144-
if (a.length !== b.length) {
145-
return false;
146-
}
147-
148-
for (let i = 0; i < a.length; i++) {
149-
if (!equals(a[i], b[i])) {
150-
return false;
151-
}
152-
}
153-
154-
return true;
155-
};
156-
};

0 commit comments

Comments
 (0)