@dxos/echo-solid 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +8 -0
- package/README.md +1 -0
- package/package.json +46 -0
- package/src/index.ts +8 -0
- package/src/useObject.test.tsx +362 -0
- package/src/useObject.ts +205 -0
- package/src/useQuery.test.tsx +277 -0
- package/src/useQuery.ts +61 -0
- package/src/useRef.test.tsx +247 -0
- package/src/useRef.ts +48 -0
- package/src/useSchema.test.tsx +208 -0
- package/src/useSchema.ts +59 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { render, waitFor } from '@solidjs/testing-library';
|
|
6
|
+
import { type JSX, createSignal } from 'solid-js';
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
|
8
|
+
|
|
9
|
+
import { Filter, Obj, Query, Type } from '@dxos/echo';
|
|
10
|
+
import { TestSchema } from '@dxos/echo/testing';
|
|
11
|
+
import { EchoTestBuilder } from '@dxos/echo-db/testing';
|
|
12
|
+
|
|
13
|
+
import { useQuery } from './useQuery';
|
|
14
|
+
|
|
15
|
+
describe('useQuery', () => {
|
|
16
|
+
let testBuilder: EchoTestBuilder;
|
|
17
|
+
let db: any;
|
|
18
|
+
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
testBuilder = new EchoTestBuilder();
|
|
21
|
+
const result = await testBuilder.createDatabase();
|
|
22
|
+
db = result.db;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(async () => {
|
|
26
|
+
await testBuilder.close();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('returns empty array when database is undefined', () => {
|
|
30
|
+
let result: any[] | undefined;
|
|
31
|
+
|
|
32
|
+
render(() => {
|
|
33
|
+
const objects = useQuery(undefined, Filter.type(Type.Expando));
|
|
34
|
+
result = objects();
|
|
35
|
+
return (<div>test</div>) as JSX.Element;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
expect(result).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('returns empty array when query matches no objects', async () => {
|
|
42
|
+
let result: any[] | undefined;
|
|
43
|
+
|
|
44
|
+
render(() => {
|
|
45
|
+
const objects = useQuery(db, Filter.type(Type.Expando));
|
|
46
|
+
result = objects();
|
|
47
|
+
return (<div>test</div>) as JSX.Element;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
await waitFor(() => {
|
|
51
|
+
expect(result).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('returns matching objects', async () => {
|
|
56
|
+
// Add some objects to the database
|
|
57
|
+
const obj1 = Obj.make(Type.Expando, { name: 'Alice' });
|
|
58
|
+
const obj2 = Obj.make(Type.Expando, { name: 'Bob' });
|
|
59
|
+
db.add(obj1);
|
|
60
|
+
db.add(obj2);
|
|
61
|
+
await db.flush({ indexes: true });
|
|
62
|
+
|
|
63
|
+
let objectsAccessor: (() => any[]) | undefined;
|
|
64
|
+
|
|
65
|
+
function TestComponent() {
|
|
66
|
+
const objects = useQuery(db, Filter.type(Type.Expando));
|
|
67
|
+
objectsAccessor = objects;
|
|
68
|
+
return (<div data-testid='count'>{objects().length}</div>) as JSX.Element;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const { getByTestId } = render(() => <TestComponent />);
|
|
72
|
+
|
|
73
|
+
await waitFor(() => {
|
|
74
|
+
expect(getByTestId('count').textContent).toBe('2');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Get the actual results from the accessor
|
|
78
|
+
const result = objectsAccessor?.() ?? [];
|
|
79
|
+
const names = result.map((obj) => obj.name).sort();
|
|
80
|
+
expect(names).toEqual(['Alice', 'Bob']);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('updates when objects are added', async () => {
|
|
84
|
+
let objectsAccessor: (() => any[]) | undefined;
|
|
85
|
+
|
|
86
|
+
function TestComponent() {
|
|
87
|
+
const objects = useQuery(db, Filter.type(Type.Expando));
|
|
88
|
+
objectsAccessor = objects;
|
|
89
|
+
return (<div data-testid='count'>{objects().length}</div>) as JSX.Element;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const { getByTestId } = render(() => <TestComponent />);
|
|
93
|
+
|
|
94
|
+
await waitFor(() => {
|
|
95
|
+
expect(getByTestId('count').textContent).toBe('0');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Add an object
|
|
99
|
+
const obj = Obj.make(Type.Expando, { name: 'Charlie' });
|
|
100
|
+
db.add(obj);
|
|
101
|
+
await db.flush({ indexes: true });
|
|
102
|
+
|
|
103
|
+
await waitFor(() => {
|
|
104
|
+
expect(getByTestId('count').textContent).toBe('1');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Get the actual results from the accessor
|
|
108
|
+
const result = objectsAccessor?.() ?? [];
|
|
109
|
+
expect(result.length).toBe(1);
|
|
110
|
+
expect(result[0]?.name).toBe('Charlie');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('updates when objects are removed', async () => {
|
|
114
|
+
// Add objects first
|
|
115
|
+
const obj1 = Obj.make(Type.Expando, { name: 'Alice' });
|
|
116
|
+
const obj2 = Obj.make(Type.Expando, { name: 'Bob' });
|
|
117
|
+
db.add(obj1);
|
|
118
|
+
db.add(obj2);
|
|
119
|
+
await db.flush({ indexes: true });
|
|
120
|
+
|
|
121
|
+
let objectsAccessor: (() => any[]) | undefined;
|
|
122
|
+
|
|
123
|
+
function TestComponent() {
|
|
124
|
+
const objects = useQuery(db, Filter.type(Type.Expando));
|
|
125
|
+
objectsAccessor = objects;
|
|
126
|
+
return (<div data-testid='count'>{objects().length}</div>) as JSX.Element;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const { getByTestId } = render(() => <TestComponent />);
|
|
130
|
+
|
|
131
|
+
await waitFor(() => {
|
|
132
|
+
expect(getByTestId('count').textContent).toBe('2');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Remove an object
|
|
136
|
+
db.remove(obj1);
|
|
137
|
+
await db.flush({ indexes: true });
|
|
138
|
+
|
|
139
|
+
await waitFor(() => {
|
|
140
|
+
expect(getByTestId('count').textContent).toBe('1');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Get the actual results from the accessor
|
|
144
|
+
const result = objectsAccessor?.() ?? [];
|
|
145
|
+
expect(result.length).toBe(1);
|
|
146
|
+
expect(result[0]?.name).toBe('Bob');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('accepts Filter directly', async () => {
|
|
150
|
+
// Register schema first
|
|
151
|
+
await db.graph.schemaRegistry.register([TestSchema.Person]);
|
|
152
|
+
|
|
153
|
+
const obj = Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' });
|
|
154
|
+
db.add(obj);
|
|
155
|
+
await db.flush({ indexes: true });
|
|
156
|
+
|
|
157
|
+
let objectsAccessor: (() => any[]) | undefined;
|
|
158
|
+
|
|
159
|
+
function TestComponent() {
|
|
160
|
+
const objects = useQuery(db, Filter.type(TestSchema.Person));
|
|
161
|
+
objectsAccessor = objects;
|
|
162
|
+
return (<div data-testid='count'>{objects().length}</div>) as JSX.Element;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const { getByTestId } = render(() => <TestComponent />);
|
|
166
|
+
|
|
167
|
+
await waitFor(() => {
|
|
168
|
+
expect(getByTestId('count').textContent).toBe('1');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Get the actual results from the accessor
|
|
172
|
+
const result = objectsAccessor?.() ?? [];
|
|
173
|
+
expect(result.length).toBe(1);
|
|
174
|
+
expect(result[0]?.name).toBe('Test');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('accepts Query directly', async () => {
|
|
178
|
+
const obj = Obj.make(Type.Expando, { name: 'Test' });
|
|
179
|
+
db.add(obj);
|
|
180
|
+
await db.flush({ indexes: true });
|
|
181
|
+
|
|
182
|
+
let objectsAccessor: (() => any[]) | undefined;
|
|
183
|
+
|
|
184
|
+
function TestComponent() {
|
|
185
|
+
const objects = useQuery(db, Query.select(Filter.type(Type.Expando)));
|
|
186
|
+
objectsAccessor = objects;
|
|
187
|
+
return (<div data-testid='count'>{objects().length}</div>) as JSX.Element;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const { getByTestId } = render(() => <TestComponent />);
|
|
191
|
+
|
|
192
|
+
await waitFor(() => {
|
|
193
|
+
expect(getByTestId('count').textContent).toBe('1');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Get the actual results from the accessor
|
|
197
|
+
const result = objectsAccessor?.() ?? [];
|
|
198
|
+
expect(result.length).toBe(1);
|
|
199
|
+
expect(result[0]?.name).toBe('Test');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('accepts reactive database accessor', async () => {
|
|
203
|
+
const obj = Obj.make(Type.Expando, { name: 'Test' });
|
|
204
|
+
db.add(obj);
|
|
205
|
+
await db.flush({ indexes: true });
|
|
206
|
+
|
|
207
|
+
let objectsAccessor: (() => any[]) | undefined;
|
|
208
|
+
let dbAccessor: any = db;
|
|
209
|
+
|
|
210
|
+
function TestComponent() {
|
|
211
|
+
const objects = useQuery(() => dbAccessor, Filter.type(Type.Expando));
|
|
212
|
+
objectsAccessor = objects;
|
|
213
|
+
return (<div data-testid='count'>{objects().length}</div>) as JSX.Element;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const { getByTestId } = render(() => <TestComponent />);
|
|
217
|
+
|
|
218
|
+
await waitFor(() => {
|
|
219
|
+
expect(getByTestId('count').textContent).toBe('1');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Get the actual results from the accessor
|
|
223
|
+
let result = objectsAccessor?.() ?? [];
|
|
224
|
+
expect(result.length).toBe(1);
|
|
225
|
+
|
|
226
|
+
// Change database to undefined
|
|
227
|
+
dbAccessor = undefined;
|
|
228
|
+
|
|
229
|
+
// Should keep previous value (no flickering)
|
|
230
|
+
result = objectsAccessor?.() ?? [];
|
|
231
|
+
expect(result.length).toBe(1);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('accepts reactive query accessor', async () => {
|
|
235
|
+
// Register schema first
|
|
236
|
+
await db.graph.schemaRegistry.register([TestSchema.Person]);
|
|
237
|
+
|
|
238
|
+
const obj1 = Obj.make(Type.Expando, { name: 'Test1' });
|
|
239
|
+
const obj2 = Obj.make(TestSchema.Person, { name: 'Test2', username: 'test', email: 'test@example.com' });
|
|
240
|
+
db.add(obj1);
|
|
241
|
+
db.add(obj2);
|
|
242
|
+
await db.flush({ indexes: true });
|
|
243
|
+
|
|
244
|
+
let objectsAccessor: (() => any[]) | undefined;
|
|
245
|
+
const [usePersonFilter, setUsePersonFilter] = createSignal(false);
|
|
246
|
+
|
|
247
|
+
function TestComponent() {
|
|
248
|
+
const objects = useQuery(db, () =>
|
|
249
|
+
usePersonFilter() ? Filter.type(TestSchema.Person) : Filter.type(Type.Expando),
|
|
250
|
+
);
|
|
251
|
+
objectsAccessor = objects;
|
|
252
|
+
return (<div data-testid='count'>{objects().length}</div>) as JSX.Element;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const { getByTestId } = render(() => <TestComponent />);
|
|
256
|
+
|
|
257
|
+
await waitFor(() => {
|
|
258
|
+
expect(getByTestId('count').textContent).toBe('1');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// Get the actual results from the accessor
|
|
262
|
+
let result = objectsAccessor?.() ?? [];
|
|
263
|
+
expect(result[0]?.name).toBe('Test1');
|
|
264
|
+
|
|
265
|
+
// Switch to person filter
|
|
266
|
+
setUsePersonFilter(true);
|
|
267
|
+
|
|
268
|
+
await waitFor(() => {
|
|
269
|
+
expect(getByTestId('count').textContent).toBe('1');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// Get the actual results from the accessor
|
|
273
|
+
result = objectsAccessor?.() ?? [];
|
|
274
|
+
expect(result.length).toBe(1);
|
|
275
|
+
expect(result[0]?.name).toBe('Test2');
|
|
276
|
+
});
|
|
277
|
+
});
|
package/src/useQuery.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Accessor, createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
|
|
6
|
+
|
|
7
|
+
import { type Database, type Entity, Filter, Query } from '@dxos/echo';
|
|
8
|
+
|
|
9
|
+
const EMPTY_ARRAY: never[] = [];
|
|
10
|
+
|
|
11
|
+
type MaybeAccessor<T> = T | Accessor<T>;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Create a reactive query subscription.
|
|
15
|
+
* Accepts either values or accessors for resource and query/filter.
|
|
16
|
+
*
|
|
17
|
+
* @param resource - The database or queryable resource (can be reactive)
|
|
18
|
+
* @param queryOrFilter - The query or filter to apply (can be reactive)
|
|
19
|
+
* @returns An accessor that returns the current query results
|
|
20
|
+
*/
|
|
21
|
+
export const useQuery = <T extends Entity.Any = Entity.Any>(
|
|
22
|
+
resource: MaybeAccessor<Database.Queryable | undefined>,
|
|
23
|
+
queryOrFilter: MaybeAccessor<Query.Any | Filter.Any>,
|
|
24
|
+
): Accessor<T[]> => {
|
|
25
|
+
// Derive the normalized query from the input
|
|
26
|
+
const query = createMemo(() => {
|
|
27
|
+
const resolved = typeof queryOrFilter === 'function' ? queryOrFilter() : queryOrFilter;
|
|
28
|
+
return Filter.is(resolved) ? Query.select(resolved) : resolved;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Derive the query result object reactively
|
|
32
|
+
const queryResult = createMemo(() => {
|
|
33
|
+
const q = query();
|
|
34
|
+
const resolvedResource = typeof resource === 'function' ? resource() : resource;
|
|
35
|
+
return resolvedResource?.query(q);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Store the current results in a signal
|
|
39
|
+
const [objects, setObjects] = createSignal<T[]>(EMPTY_ARRAY as T[]);
|
|
40
|
+
|
|
41
|
+
// Subscribe to query result changes
|
|
42
|
+
createEffect(() => {
|
|
43
|
+
const result = queryResult();
|
|
44
|
+
if (!result) {
|
|
45
|
+
// Keep previous value during transitions to prevent flickering
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Subscribe with immediate fire to get initial results
|
|
50
|
+
const unsubscribe = result.subscribe(
|
|
51
|
+
() => {
|
|
52
|
+
setObjects(() => result.results as T[]);
|
|
53
|
+
},
|
|
54
|
+
{ fire: true },
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
onCleanup(unsubscribe);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return objects;
|
|
61
|
+
};
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { render, waitFor } from '@solidjs/testing-library';
|
|
6
|
+
import { type JSX, createSignal } from 'solid-js';
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
|
8
|
+
|
|
9
|
+
import { Obj, Ref, Type } from '@dxos/echo';
|
|
10
|
+
import { TestSchema } from '@dxos/echo/testing';
|
|
11
|
+
import { EchoTestBuilder } from '@dxos/echo-db/testing';
|
|
12
|
+
import { Registry, RegistryProvider } from '@dxos/effect-atom-solid';
|
|
13
|
+
|
|
14
|
+
import { useRef } from './useRef';
|
|
15
|
+
|
|
16
|
+
const createWrapper = (registry: Registry.Registry) => {
|
|
17
|
+
return (props: { children: JSX.Element }) => {
|
|
18
|
+
return <RegistryProvider registry={registry}>{props.children}</RegistryProvider>;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe('useRef', () => {
|
|
23
|
+
let testBuilder: EchoTestBuilder;
|
|
24
|
+
let db: any;
|
|
25
|
+
let registry: Registry.Registry;
|
|
26
|
+
|
|
27
|
+
beforeEach(async () => {
|
|
28
|
+
testBuilder = await new EchoTestBuilder().open();
|
|
29
|
+
const { db: database } = await testBuilder.createDatabase();
|
|
30
|
+
db = database;
|
|
31
|
+
registry = Registry.make();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(async () => {
|
|
35
|
+
await testBuilder.close();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('returns undefined when ref is undefined', () => {
|
|
39
|
+
const Wrapper = createWrapper(registry);
|
|
40
|
+
|
|
41
|
+
let result: any;
|
|
42
|
+
|
|
43
|
+
render(
|
|
44
|
+
() => {
|
|
45
|
+
const target = useRef(undefined);
|
|
46
|
+
result = target();
|
|
47
|
+
return (<div>test</div>) as JSX.Element;
|
|
48
|
+
},
|
|
49
|
+
{ wrapper: Wrapper },
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
expect(result).toBeUndefined();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('returns undefined when ref target is not loaded and loads it', async () => {
|
|
56
|
+
await db.graph.schemaRegistry.register([TestSchema.Person]);
|
|
57
|
+
|
|
58
|
+
// Create objects with a ref
|
|
59
|
+
const targetObj = Obj.make(TestSchema.Person, { name: 'Target', username: 'target', email: 'target@example.com' });
|
|
60
|
+
db.add(targetObj);
|
|
61
|
+
await db.flush({ indexes: true });
|
|
62
|
+
|
|
63
|
+
// Create a ref - target should be available since object is in memory
|
|
64
|
+
const ref = Ref.make(targetObj);
|
|
65
|
+
|
|
66
|
+
// Initially target should be available since object is in memory
|
|
67
|
+
expect(ref.target).toBeDefined();
|
|
68
|
+
|
|
69
|
+
const Wrapper = createWrapper(registry);
|
|
70
|
+
|
|
71
|
+
let targetAccessor: (() => any) | undefined;
|
|
72
|
+
|
|
73
|
+
function TestComponent() {
|
|
74
|
+
const target = useRef(ref);
|
|
75
|
+
targetAccessor = target;
|
|
76
|
+
return (<div data-testid='name'>{target()?.name || 'undefined'}</div>) as JSX.Element;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const { getByTestId } = render(() => <TestComponent />, { wrapper: Wrapper });
|
|
80
|
+
|
|
81
|
+
// Should load since target is available
|
|
82
|
+
await waitFor(() => {
|
|
83
|
+
expect(getByTestId('name').textContent).toBe('Target');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Get the actual result from the accessor
|
|
87
|
+
const result = targetAccessor?.();
|
|
88
|
+
expect(result?.name).toBe('Target');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('returns target when ref is loaded', async () => {
|
|
92
|
+
await db.graph.schemaRegistry.register([TestSchema.Person]);
|
|
93
|
+
|
|
94
|
+
const targetObj = Obj.make(TestSchema.Person, { name: 'Target', username: 'target', email: 'target@example.com' });
|
|
95
|
+
db.add(targetObj);
|
|
96
|
+
await db.flush({ indexes: true });
|
|
97
|
+
|
|
98
|
+
const ref = Ref.make(targetObj);
|
|
99
|
+
const Wrapper = createWrapper(registry);
|
|
100
|
+
|
|
101
|
+
let targetAccessor: (() => any) | undefined;
|
|
102
|
+
|
|
103
|
+
function TestComponent() {
|
|
104
|
+
const target = useRef(ref);
|
|
105
|
+
targetAccessor = target;
|
|
106
|
+
return (<div data-testid='name'>{target()?.name || 'undefined'}</div>) as JSX.Element;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const { getByTestId } = render(() => <TestComponent />, { wrapper: Wrapper });
|
|
110
|
+
|
|
111
|
+
await waitFor(() => {
|
|
112
|
+
expect(getByTestId('name').textContent).toBe('Target');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Get the actual result from the accessor
|
|
116
|
+
const result = targetAccessor?.();
|
|
117
|
+
expect(result?.name).toBe('Target');
|
|
118
|
+
expect(result?.username).toBe('target');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('handles ref to Expando object', async () => {
|
|
122
|
+
const targetObj = Obj.make(Type.Expando, { name: 'Expando Target', value: 42 });
|
|
123
|
+
db.add(targetObj);
|
|
124
|
+
await db.flush({ indexes: true });
|
|
125
|
+
|
|
126
|
+
const ref = Ref.make(targetObj);
|
|
127
|
+
const Wrapper = createWrapper(registry);
|
|
128
|
+
|
|
129
|
+
let targetAccessor: (() => any) | undefined;
|
|
130
|
+
|
|
131
|
+
function TestComponent() {
|
|
132
|
+
const target = useRef(ref);
|
|
133
|
+
targetAccessor = target;
|
|
134
|
+
return (<div data-testid='name'>{target()?.name || 'undefined'}</div>) as JSX.Element;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const { getByTestId } = render(() => <TestComponent />, { wrapper: Wrapper });
|
|
138
|
+
|
|
139
|
+
await waitFor(() => {
|
|
140
|
+
expect(getByTestId('name').textContent).toBe('Expando Target');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Get the actual result from the accessor
|
|
144
|
+
const result = targetAccessor?.();
|
|
145
|
+
expect(result?.name).toBe('Expando Target');
|
|
146
|
+
expect(result?.value).toBe(42);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('works with accessor function', async () => {
|
|
150
|
+
await db.graph.schemaRegistry.register([TestSchema.Person]);
|
|
151
|
+
|
|
152
|
+
const targetObj = Obj.make(TestSchema.Person, { name: 'Target', username: 'target', email: 'target@example.com' });
|
|
153
|
+
db.add(targetObj);
|
|
154
|
+
await db.flush({ indexes: true });
|
|
155
|
+
|
|
156
|
+
const ref = Ref.make(targetObj);
|
|
157
|
+
const Wrapper = createWrapper(registry);
|
|
158
|
+
|
|
159
|
+
let targetAccessor: (() => any) | undefined;
|
|
160
|
+
|
|
161
|
+
function TestComponent() {
|
|
162
|
+
const target = useRef(() => ref);
|
|
163
|
+
targetAccessor = target;
|
|
164
|
+
return (<div data-testid='name'>{target()?.name || 'undefined'}</div>) as JSX.Element;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const { getByTestId } = render(() => <TestComponent />, { wrapper: Wrapper });
|
|
168
|
+
|
|
169
|
+
await waitFor(() => {
|
|
170
|
+
expect(getByTestId('name').textContent).toBe('Target');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Get the actual result from the accessor
|
|
174
|
+
const result = targetAccessor?.();
|
|
175
|
+
expect(result?.name).toBe('Target');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('reactively tracks changes when accessor returns different ref', async () => {
|
|
179
|
+
await db.graph.schemaRegistry.register([TestSchema.Person]);
|
|
180
|
+
|
|
181
|
+
const targetObj1 = Obj.make(TestSchema.Person, {
|
|
182
|
+
name: 'Target1',
|
|
183
|
+
username: 'target1',
|
|
184
|
+
email: 'target1@example.com',
|
|
185
|
+
});
|
|
186
|
+
const targetObj2 = Obj.make(TestSchema.Person, {
|
|
187
|
+
name: 'Target2',
|
|
188
|
+
username: 'target2',
|
|
189
|
+
email: 'target2@example.com',
|
|
190
|
+
});
|
|
191
|
+
db.add(targetObj1);
|
|
192
|
+
db.add(targetObj2);
|
|
193
|
+
await db.flush({ indexes: true });
|
|
194
|
+
|
|
195
|
+
const ref1 = Ref.make(targetObj1);
|
|
196
|
+
const ref2 = Ref.make(targetObj2);
|
|
197
|
+
const [refSignal, setRefSignal] = createSignal<Ref.Ref<any> | undefined>(ref1);
|
|
198
|
+
const Wrapper = createWrapper(registry);
|
|
199
|
+
|
|
200
|
+
let targetAccessor: (() => any) | undefined;
|
|
201
|
+
|
|
202
|
+
function TestComponent() {
|
|
203
|
+
const target = useRef(refSignal);
|
|
204
|
+
targetAccessor = target;
|
|
205
|
+
return (<div data-testid='name'>{target()?.name || 'undefined'}</div>) as JSX.Element;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const { getByTestId } = render(() => <TestComponent />, { wrapper: Wrapper });
|
|
209
|
+
|
|
210
|
+
await waitFor(() => {
|
|
211
|
+
expect(getByTestId('name').textContent).toBe('Target1');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// Get the actual result from the accessor
|
|
215
|
+
let result = targetAccessor?.();
|
|
216
|
+
expect(result?.name).toBe('Target1');
|
|
217
|
+
|
|
218
|
+
// Change the ref via signal
|
|
219
|
+
setRefSignal(() => ref2);
|
|
220
|
+
|
|
221
|
+
await waitFor(() => {
|
|
222
|
+
expect(getByTestId('name').textContent).toBe('Target2');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// Get the actual result from the accessor
|
|
226
|
+
result = targetAccessor?.();
|
|
227
|
+
expect(result?.name).toBe('Target2');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('handles undefined when accessor returns undefined', () => {
|
|
231
|
+
const [refSignal] = createSignal<Ref.Ref<any> | undefined>(undefined);
|
|
232
|
+
const Wrapper = createWrapper(registry);
|
|
233
|
+
|
|
234
|
+
let targetAccessor: (() => any) | undefined;
|
|
235
|
+
|
|
236
|
+
function TestComponent() {
|
|
237
|
+
const target = useRef(refSignal);
|
|
238
|
+
targetAccessor = target;
|
|
239
|
+
return (<div data-testid='name'>{target()?.name || 'undefined'}</div>) as JSX.Element;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const { getByTestId } = render(() => <TestComponent />, { wrapper: Wrapper });
|
|
243
|
+
|
|
244
|
+
expect(getByTestId('name').textContent).toBe('undefined');
|
|
245
|
+
expect(targetAccessor?.()).toBeUndefined();
|
|
246
|
+
});
|
|
247
|
+
});
|
package/src/useRef.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type MaybeAccessor, access } from '@solid-primitives/utils';
|
|
6
|
+
import { type Accessor, createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
|
|
7
|
+
|
|
8
|
+
import { type Entity, type Ref } from '@dxos/echo';
|
|
9
|
+
import { AtomRef } from '@dxos/echo-atom';
|
|
10
|
+
import { useRegistry } from '@dxos/effect-atom-solid';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Subscribe to a reference target object.
|
|
14
|
+
* Returns undefined if the reference hasn't loaded yet, and automatically updates when the target loads or changes.
|
|
15
|
+
*
|
|
16
|
+
* @param ref - The reference to subscribe to (can be reactive).
|
|
17
|
+
* @returns An accessor that returns the current target object or undefined if not loaded.
|
|
18
|
+
*/
|
|
19
|
+
export function useRef<T extends Entity.Unknown>(ref: MaybeAccessor<Ref.Ref<T> | undefined>): Accessor<T | undefined> {
|
|
20
|
+
const registry = useRegistry();
|
|
21
|
+
|
|
22
|
+
// Memoize the ref to track changes.
|
|
23
|
+
const memoizedRef = createMemo(() => access(ref));
|
|
24
|
+
|
|
25
|
+
// Store the current target in a signal.
|
|
26
|
+
const [target, setTarget] = createSignal<T | undefined>(undefined);
|
|
27
|
+
|
|
28
|
+
// Subscribe to ref target changes.
|
|
29
|
+
createEffect(() => {
|
|
30
|
+
const currentRef = memoizedRef();
|
|
31
|
+
|
|
32
|
+
const atom = AtomRef.make(currentRef);
|
|
33
|
+
const currentValue = registry.get(atom);
|
|
34
|
+
setTarget(() => currentValue);
|
|
35
|
+
|
|
36
|
+
const unsubscribe = registry.subscribe(
|
|
37
|
+
atom,
|
|
38
|
+
() => {
|
|
39
|
+
setTarget(() => registry.get(atom));
|
|
40
|
+
},
|
|
41
|
+
{ immediate: true },
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
onCleanup(unsubscribe);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return target;
|
|
48
|
+
}
|