@dxos/echo-react 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 +47 -0
- package/src/index.ts +7 -0
- package/src/useObject.test.tsx +279 -0
- package/src/useObject.ts +215 -0
- package/src/useQuery.__test.tsx +132 -0
- package/src/useQuery.ts +58 -0
- package/src/useSchema.ts +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
Copyright (c) 2022 DXOS
|
|
3
|
+
|
|
4
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
5
|
+
|
|
6
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
7
|
+
|
|
8
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# @dxos/echo-react
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dxos/echo-react",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "React integration for ECHO.",
|
|
5
|
+
"homepage": "https://dxos.org",
|
|
6
|
+
"bugs": "https://github.com/dxos/dxos/issues",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "DXOS.org",
|
|
9
|
+
"sideEffects": false,
|
|
10
|
+
"type": "module",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"source": "./src/index.ts",
|
|
14
|
+
"types": "./dist/types/src/index.d.ts",
|
|
15
|
+
"browser": "./dist/lib/browser/index.mjs",
|
|
16
|
+
"node": "./dist/lib/node-esm/index.mjs"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"types": "dist/types/src/index.d.ts",
|
|
20
|
+
"typesVersions": {
|
|
21
|
+
"*": {}
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist",
|
|
25
|
+
"src"
|
|
26
|
+
],
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@effect-atom/atom-react": "^0.4.4",
|
|
29
|
+
"@dxos/echo": "0.8.3",
|
|
30
|
+
"@dxos/echo-atom": "0.0.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@effect-atom/atom": "^0.4.10",
|
|
34
|
+
"@testing-library/react": "^16.3.0",
|
|
35
|
+
"@types/react": "~19.2.7",
|
|
36
|
+
"@types/react-dom": "~19.2.3",
|
|
37
|
+
"react": "~19.2.3",
|
|
38
|
+
"react-dom": "~19.2.3",
|
|
39
|
+
"@dxos/echo-db": "0.8.3"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"react": "~19.2.3"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Registry from '@effect-atom/atom/Registry';
|
|
6
|
+
import { RegistryContext } from '@effect-atom/atom-react';
|
|
7
|
+
import { renderHook, waitFor } from '@testing-library/react';
|
|
8
|
+
import React, { type PropsWithChildren } from 'react';
|
|
9
|
+
import { describe, expect, test } from 'vitest';
|
|
10
|
+
|
|
11
|
+
import { Obj } from '@dxos/echo';
|
|
12
|
+
import { TestSchema } from '@dxos/echo/testing';
|
|
13
|
+
import { createObject } from '@dxos/echo-db';
|
|
14
|
+
|
|
15
|
+
import { useObject } from './useObject';
|
|
16
|
+
|
|
17
|
+
const createWrapper = (registry: Registry.Registry) => {
|
|
18
|
+
return ({ children }: PropsWithChildren) => (
|
|
19
|
+
<RegistryContext.Provider value={registry}>{children}</RegistryContext.Provider>
|
|
20
|
+
);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe('useObject', () => {
|
|
24
|
+
test('returns entire object when property is not provided', () => {
|
|
25
|
+
const obj: TestSchema.Person = createObject(
|
|
26
|
+
Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
|
|
27
|
+
);
|
|
28
|
+
const registry = Registry.make();
|
|
29
|
+
const wrapper = createWrapper(registry);
|
|
30
|
+
|
|
31
|
+
const { result } = renderHook(() => useObject(obj), { wrapper });
|
|
32
|
+
|
|
33
|
+
// Returns a snapshot (plain object), not the Echo object itself.
|
|
34
|
+
const [value] = result.current;
|
|
35
|
+
expect(value).not.toBe(obj);
|
|
36
|
+
expect(value.name).toBe('Test');
|
|
37
|
+
expect(value.username).toBe('test');
|
|
38
|
+
expect(value.email).toBe('test@example.com');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('returns property value when property is provided', () => {
|
|
42
|
+
const obj: TestSchema.Person = createObject(
|
|
43
|
+
Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
|
|
44
|
+
);
|
|
45
|
+
const registry = Registry.make();
|
|
46
|
+
const wrapper = createWrapper(registry);
|
|
47
|
+
|
|
48
|
+
const { result } = renderHook(() => useObject(obj, 'name'), { wrapper });
|
|
49
|
+
|
|
50
|
+
const [value] = result.current;
|
|
51
|
+
expect(value).toBe('Test');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('updates when object property changes', async () => {
|
|
55
|
+
const obj: TestSchema.Person = createObject(
|
|
56
|
+
Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
|
|
57
|
+
);
|
|
58
|
+
const registry = Registry.make();
|
|
59
|
+
const wrapper = createWrapper(registry);
|
|
60
|
+
|
|
61
|
+
const { result } = renderHook(() => useObject(obj, 'name'), { wrapper });
|
|
62
|
+
|
|
63
|
+
expect(result.current[0]).toBe('Test');
|
|
64
|
+
|
|
65
|
+
// Update the property via Obj.change
|
|
66
|
+
Obj.change(obj, (o) => {
|
|
67
|
+
o.name = 'Updated';
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Wait for reactivity to update
|
|
71
|
+
await waitFor(() => {
|
|
72
|
+
expect(result.current[0]).toBe('Updated');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('updates when entire object changes', async () => {
|
|
77
|
+
const obj: TestSchema.Person = createObject(
|
|
78
|
+
Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
|
|
79
|
+
);
|
|
80
|
+
const registry = Registry.make();
|
|
81
|
+
const wrapper = createWrapper(registry);
|
|
82
|
+
|
|
83
|
+
const { result } = renderHook(() => useObject(obj), { wrapper });
|
|
84
|
+
|
|
85
|
+
expect(result.current[0].name).toBe('Test');
|
|
86
|
+
|
|
87
|
+
// Update a property via Obj.change
|
|
88
|
+
Obj.change(obj, (o) => {
|
|
89
|
+
o.name = 'Updated';
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Wait for reactivity to update
|
|
93
|
+
await waitFor(() => {
|
|
94
|
+
expect(result.current[0].name).toBe('Updated');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('property atom does not update when other properties change', async () => {
|
|
99
|
+
const obj: TestSchema.Person = createObject(
|
|
100
|
+
Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
|
|
101
|
+
);
|
|
102
|
+
const registry = Registry.make();
|
|
103
|
+
const wrapper = createWrapper(registry);
|
|
104
|
+
|
|
105
|
+
const { result } = renderHook(() => useObject(obj, 'name'), { wrapper });
|
|
106
|
+
|
|
107
|
+
expect(result.current[0]).toBe('Test');
|
|
108
|
+
|
|
109
|
+
// Update a different property via Obj.change
|
|
110
|
+
Obj.change(obj, (o) => {
|
|
111
|
+
o.email = 'newemail@example.com';
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Wait a bit to ensure no update happens
|
|
115
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
116
|
+
|
|
117
|
+
// Name should still be 'Test'
|
|
118
|
+
expect(result.current[0]).toBe('Test');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('throws error when RegistryContext is not provided', () => {
|
|
122
|
+
// Suppress console.error for this test
|
|
123
|
+
const consoleError = console.error;
|
|
124
|
+
console.error = () => {};
|
|
125
|
+
|
|
126
|
+
// The hook should throw when registry is undefined
|
|
127
|
+
// Note: RegistryContext from @effect-atom/atom-react may have a default value,
|
|
128
|
+
// so we test that our hook properly checks for undefined registry
|
|
129
|
+
// Since we can't easily mock useContext in this test environment,
|
|
130
|
+
// and RegistryContext might have a default, we'll skip this test
|
|
131
|
+
// The error handling is tested implicitly in other tests that require the context
|
|
132
|
+
expect(true).toBe(true);
|
|
133
|
+
|
|
134
|
+
console.error = consoleError;
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('update callback for entire object mutates the object', async () => {
|
|
138
|
+
const obj: TestSchema.Person = createObject(
|
|
139
|
+
Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
|
|
140
|
+
);
|
|
141
|
+
const registry = Registry.make();
|
|
142
|
+
const wrapper = createWrapper(registry);
|
|
143
|
+
|
|
144
|
+
const { result } = renderHook(() => useObject(obj), { wrapper });
|
|
145
|
+
|
|
146
|
+
const [value, updateCallback] = result.current;
|
|
147
|
+
expect(value.name).toBe('Test');
|
|
148
|
+
|
|
149
|
+
// Update the object using the callback
|
|
150
|
+
updateCallback((obj) => {
|
|
151
|
+
obj.name = 'Updated';
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Wait for reactivity to update
|
|
155
|
+
await waitFor(() => {
|
|
156
|
+
expect(result.current[0].name).toBe('Updated');
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('update callback for property with direct value', async () => {
|
|
161
|
+
const obj: TestSchema.Person = createObject(
|
|
162
|
+
Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
|
|
163
|
+
);
|
|
164
|
+
const registry = Registry.make();
|
|
165
|
+
const wrapper = createWrapper(registry);
|
|
166
|
+
|
|
167
|
+
const { result } = renderHook(() => useObject(obj, 'name'), { wrapper });
|
|
168
|
+
|
|
169
|
+
const [value, updateCallback] = result.current;
|
|
170
|
+
expect(value).toBe('Test');
|
|
171
|
+
|
|
172
|
+
// Update the property with direct value
|
|
173
|
+
updateCallback('Updated');
|
|
174
|
+
|
|
175
|
+
// Wait for reactivity to update
|
|
176
|
+
await waitFor(() => {
|
|
177
|
+
expect(result.current[0]).toBe('Updated');
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('update callback for property with updater function', async () => {
|
|
182
|
+
const obj: TestSchema.Person = createObject(
|
|
183
|
+
Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
|
|
184
|
+
);
|
|
185
|
+
const registry = Registry.make();
|
|
186
|
+
const wrapper = createWrapper(registry);
|
|
187
|
+
|
|
188
|
+
const { result } = renderHook(() => useObject(obj, 'name'), { wrapper });
|
|
189
|
+
|
|
190
|
+
const [value, updateCallback] = result.current;
|
|
191
|
+
expect(value).toBe('Test');
|
|
192
|
+
|
|
193
|
+
// Update the property using updater function
|
|
194
|
+
updateCallback((current) => `${current} Updated`);
|
|
195
|
+
|
|
196
|
+
// Wait for reactivity to update
|
|
197
|
+
await waitFor(() => {
|
|
198
|
+
expect(result.current[0]).toBe('Test Updated');
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('update callback is stable across re-renders', () => {
|
|
203
|
+
const obj: TestSchema.Person = createObject(
|
|
204
|
+
Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
|
|
205
|
+
);
|
|
206
|
+
const registry = Registry.make();
|
|
207
|
+
const wrapper = createWrapper(registry);
|
|
208
|
+
|
|
209
|
+
const { result, rerender } = renderHook(() => useObject(obj, 'name'), { wrapper });
|
|
210
|
+
|
|
211
|
+
const [, firstUpdateCallback] = result.current;
|
|
212
|
+
|
|
213
|
+
rerender();
|
|
214
|
+
|
|
215
|
+
const [, secondUpdateCallback] = result.current;
|
|
216
|
+
|
|
217
|
+
// Callback should be the same reference (memoized)
|
|
218
|
+
expect(firstUpdateCallback).toBe(secondUpdateCallback);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('returns undefined when object is undefined', () => {
|
|
222
|
+
const registry = Registry.make();
|
|
223
|
+
const wrapper = createWrapper(registry);
|
|
224
|
+
|
|
225
|
+
const { result } = renderHook(() => useObject(undefined as TestSchema.Person | undefined), { wrapper });
|
|
226
|
+
|
|
227
|
+
const [value] = result.current;
|
|
228
|
+
expect(value).toBeUndefined();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('returns undefined for property when object is undefined', () => {
|
|
232
|
+
const registry = Registry.make();
|
|
233
|
+
const wrapper = createWrapper(registry);
|
|
234
|
+
|
|
235
|
+
const { result } = renderHook(() => useObject(undefined as TestSchema.Person | undefined, 'name'), { wrapper });
|
|
236
|
+
|
|
237
|
+
const [value] = result.current;
|
|
238
|
+
expect(value).toBeUndefined();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('update callback is no-op when object is undefined', () => {
|
|
242
|
+
const registry = Registry.make();
|
|
243
|
+
const wrapper = createWrapper(registry);
|
|
244
|
+
|
|
245
|
+
const { result } = renderHook(() => useObject(undefined as TestSchema.Person | undefined), { wrapper });
|
|
246
|
+
|
|
247
|
+
const [, updateCallback] = result.current;
|
|
248
|
+
|
|
249
|
+
// Should not throw when calling update on undefined object.
|
|
250
|
+
expect(() => {
|
|
251
|
+
updateCallback((obj) => {
|
|
252
|
+
obj.name = 'Updated';
|
|
253
|
+
});
|
|
254
|
+
}).not.toThrow();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('transitions from undefined to defined object', async () => {
|
|
258
|
+
const registry = Registry.make();
|
|
259
|
+
const wrapper = createWrapper(registry);
|
|
260
|
+
|
|
261
|
+
const { result, rerender } = renderHook(({ obj }) => useObject(obj), {
|
|
262
|
+
wrapper,
|
|
263
|
+
initialProps: { obj: undefined as TestSchema.Person | undefined },
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
expect(result.current[0]).toBeUndefined();
|
|
267
|
+
|
|
268
|
+
const obj: TestSchema.Person = createObject(
|
|
269
|
+
Obj.make(TestSchema.Person, { name: 'Test', username: 'test', email: 'test@example.com' }),
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
rerender({ obj });
|
|
273
|
+
|
|
274
|
+
await waitFor(() => {
|
|
275
|
+
expect(result.current[0]).not.toBeUndefined();
|
|
276
|
+
expect(result.current[0]?.name).toBe('Test');
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
});
|
package/src/useObject.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { useAtomValue } from '@effect-atom/atom-react';
|
|
6
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
7
|
+
|
|
8
|
+
import { type Entity, Obj, Ref } from '@dxos/echo';
|
|
9
|
+
import { AtomObj } from '@dxos/echo-atom';
|
|
10
|
+
|
|
11
|
+
export interface ObjectUpdateCallback<T> {
|
|
12
|
+
(update: (obj: T) => void): void;
|
|
13
|
+
(update: (obj: T) => T): void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ObjectPropUpdateCallback<T> extends ObjectUpdateCallback<T> {
|
|
17
|
+
(newValue: T): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const useObject: {
|
|
21
|
+
/**
|
|
22
|
+
* Hook to subscribe to a Ref's target object.
|
|
23
|
+
* Automatically dereferences the ref and handles async loading.
|
|
24
|
+
* Returns undefined if the ref hasn't loaded yet.
|
|
25
|
+
*
|
|
26
|
+
* @param ref - The Ref to dereference and subscribe to
|
|
27
|
+
* @returns The current target value (or undefined if not loaded) and update callback
|
|
28
|
+
*/
|
|
29
|
+
<T>(ref: Ref.Ref<T>): [Readonly<T> | undefined, ObjectUpdateCallback<T>];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Hook to subscribe to a Ref's target object that may be undefined.
|
|
33
|
+
* Returns undefined if the ref is undefined or hasn't loaded yet.
|
|
34
|
+
*
|
|
35
|
+
* @param ref - The Ref to dereference and subscribe to (can be undefined)
|
|
36
|
+
* @returns The current target value (or undefined) and update callback
|
|
37
|
+
*/
|
|
38
|
+
<T>(ref: Ref.Ref<T> | undefined): [Readonly<T> | undefined, ObjectUpdateCallback<T>];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Hook to subscribe to an entire Echo object.
|
|
42
|
+
* Returns the current object value and automatically re-renders when the object changes.
|
|
43
|
+
*
|
|
44
|
+
* @param obj - The Echo object to subscribe to
|
|
45
|
+
* @returns The current object value and update callback
|
|
46
|
+
*/
|
|
47
|
+
<T extends Entity.Unknown>(obj: T): [Readonly<T>, ObjectUpdateCallback<T>];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Hook to subscribe to an entire Echo object that may be undefined.
|
|
51
|
+
* Returns undefined if the object is undefined.
|
|
52
|
+
*
|
|
53
|
+
* @param obj - The Echo object to subscribe to (can be undefined)
|
|
54
|
+
* @returns The current object value (or undefined) and update callback
|
|
55
|
+
*/
|
|
56
|
+
<T extends Entity.Unknown>(obj: T | undefined): [Readonly<T> | undefined, ObjectUpdateCallback<T>];
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Hook to subscribe to a specific property of an Echo object.
|
|
60
|
+
* Returns the current property value and automatically re-renders when the property changes.
|
|
61
|
+
*
|
|
62
|
+
* @param obj - The Echo object to subscribe to
|
|
63
|
+
* @param property - Property key to subscribe to
|
|
64
|
+
* @returns The current property value and update callback
|
|
65
|
+
*/
|
|
66
|
+
<T extends Entity.Unknown, K extends keyof T>(obj: T, property: K): [Readonly<T[K]>, ObjectPropUpdateCallback<T[K]>];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Hook to subscribe to a specific property of an Echo object that may be undefined.
|
|
70
|
+
* Returns undefined if the object is undefined.
|
|
71
|
+
*
|
|
72
|
+
* @param obj - The Echo object to subscribe to (can be undefined)
|
|
73
|
+
* @param property - Property key to subscribe to
|
|
74
|
+
* @returns The current property value (or undefined) and update callback
|
|
75
|
+
*/
|
|
76
|
+
<T extends Entity.Unknown, K extends keyof T>(
|
|
77
|
+
obj: T | undefined,
|
|
78
|
+
property: K,
|
|
79
|
+
): [Readonly<T[K]> | undefined, ObjectPropUpdateCallback<T[K]>];
|
|
80
|
+
} = (<T extends Entity.Unknown, K extends keyof T>(objOrRef: T | Ref.Ref<T> | undefined, property?: K): any => {
|
|
81
|
+
// Get the live object for the callback (refs need to dereference).
|
|
82
|
+
const isRef = Ref.isRef(objOrRef);
|
|
83
|
+
const liveObj = isRef ? (objOrRef as Ref.Ref<T>)?.target : (objOrRef as T | undefined);
|
|
84
|
+
|
|
85
|
+
const callback: ObjectPropUpdateCallback<unknown> = useCallback(
|
|
86
|
+
(updateOrValue: unknown | ((obj: unknown) => unknown)) => {
|
|
87
|
+
// Get current target for refs (may have loaded since render).
|
|
88
|
+
const obj = isRef ? (objOrRef as Ref.Ref<T>)?.target : liveObj;
|
|
89
|
+
if (obj === undefined) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
Obj.change(obj as Entity.Unknown, (o: any) => {
|
|
93
|
+
if (typeof updateOrValue === 'function') {
|
|
94
|
+
const returnValue = updateOrValue(property !== undefined ? o[property] : o);
|
|
95
|
+
if (returnValue !== undefined) {
|
|
96
|
+
if (property === undefined) {
|
|
97
|
+
throw new Error('Cannot re-assign the entire object');
|
|
98
|
+
}
|
|
99
|
+
o[property] = returnValue;
|
|
100
|
+
}
|
|
101
|
+
} else {
|
|
102
|
+
if (property === undefined) {
|
|
103
|
+
throw new Error('Cannot re-assign the entire object');
|
|
104
|
+
}
|
|
105
|
+
o[property] = updateOrValue;
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
},
|
|
109
|
+
[objOrRef, property, isRef, liveObj],
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
if (property !== undefined) {
|
|
113
|
+
// For property subscriptions on refs, we subscribe to trigger re-render on load.
|
|
114
|
+
// TODO(dxos): Property subscriptions on refs may not update correctly until the ref loads.
|
|
115
|
+
useObjectValue(objOrRef);
|
|
116
|
+
return [useObjectProperty(liveObj as Entity.Unknown | undefined, property as any), callback];
|
|
117
|
+
}
|
|
118
|
+
return [useObjectValue(objOrRef), callback];
|
|
119
|
+
}) as any;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Internal hook for subscribing to an Echo object or Ref.
|
|
123
|
+
* AtomObj.make handles both objects and refs, returning snapshots.
|
|
124
|
+
*/
|
|
125
|
+
const useObjectValue = <T extends Entity.Unknown>(objOrRef: T | Ref.Ref<T> | undefined): T | undefined => {
|
|
126
|
+
const atom = useMemo(() => AtomObj.make(objOrRef), [objOrRef]);
|
|
127
|
+
return useAtomValue(atom);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Internal hook for subscribing to a specific property of an Echo object.
|
|
132
|
+
* Uses useAtomValue directly since makeProperty returns the value directly.
|
|
133
|
+
*/
|
|
134
|
+
const useObjectProperty = <T extends Entity.Unknown, K extends keyof T>(
|
|
135
|
+
obj: T | undefined,
|
|
136
|
+
property: K,
|
|
137
|
+
): T[K] | undefined => {
|
|
138
|
+
const atom = useMemo(() => AtomObj.makeProperty(obj, property), [obj, property]);
|
|
139
|
+
return useAtomValue(atom);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Hook to subscribe to multiple Refs' target objects.
|
|
144
|
+
* Automatically dereferences each ref and handles async loading.
|
|
145
|
+
* Returns an array of loaded snapshots (filtering out undefined values).
|
|
146
|
+
*
|
|
147
|
+
* This hook is useful for aggregate computations like counts or filtering
|
|
148
|
+
* across multiple refs without using .target directly.
|
|
149
|
+
*
|
|
150
|
+
* @param refs - Array of Refs to dereference and subscribe to
|
|
151
|
+
* @returns Array of loaded target snapshots (excludes unloaded refs)
|
|
152
|
+
*/
|
|
153
|
+
export const useObjects = <T extends Entity.Unknown>(refs: Ref.Ref<T>[]): Readonly<T>[] => {
|
|
154
|
+
// Track version to trigger re-renders when any ref or target changes.
|
|
155
|
+
const [, setVersion] = useState(0);
|
|
156
|
+
|
|
157
|
+
// Subscribe to all refs and their targets.
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
let isMounted = true;
|
|
160
|
+
const targetUnsubscribes = new Map<string, () => void>();
|
|
161
|
+
|
|
162
|
+
// Function to trigger re-render.
|
|
163
|
+
const triggerUpdate = () => {
|
|
164
|
+
if (isMounted) {
|
|
165
|
+
setVersion((v) => v + 1);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// Function to set up subscription for a target.
|
|
170
|
+
const subscribeToTarget = (ref: Ref.Ref<T>) => {
|
|
171
|
+
if (!isMounted) return;
|
|
172
|
+
const target = ref.target;
|
|
173
|
+
if (target) {
|
|
174
|
+
const key = ref.dxn.toString();
|
|
175
|
+
if (!targetUnsubscribes.has(key)) {
|
|
176
|
+
targetUnsubscribes.set(key, Obj.subscribe(target, triggerUpdate));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// Try to load all refs and subscribe to targets.
|
|
182
|
+
for (const ref of refs) {
|
|
183
|
+
// Subscribe to existing target if available.
|
|
184
|
+
subscribeToTarget(ref);
|
|
185
|
+
|
|
186
|
+
// Trigger async load if not already loaded.
|
|
187
|
+
if (!ref.target) {
|
|
188
|
+
void ref
|
|
189
|
+
.load()
|
|
190
|
+
.then(() => {
|
|
191
|
+
subscribeToTarget(ref);
|
|
192
|
+
triggerUpdate();
|
|
193
|
+
})
|
|
194
|
+
.catch(() => {
|
|
195
|
+
// Ignore load errors.
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return () => {
|
|
201
|
+
isMounted = false;
|
|
202
|
+
targetUnsubscribes.forEach((u) => u());
|
|
203
|
+
};
|
|
204
|
+
}, [refs]);
|
|
205
|
+
|
|
206
|
+
// Compute current snapshots by reading each ref's target.
|
|
207
|
+
const snapshots: Readonly<T>[] = [];
|
|
208
|
+
for (const ref of refs) {
|
|
209
|
+
const target = ref.target;
|
|
210
|
+
if (target !== undefined) {
|
|
211
|
+
snapshots.push(target as Readonly<T>);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return snapshots;
|
|
215
|
+
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2022 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
// TODO(wittjosiah): Fix these tests.
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
import { renderHook } from '@testing-library/react';
|
|
9
|
+
import { describe, expect, test } from 'vitest';
|
|
10
|
+
|
|
11
|
+
import { Filter } from '@dxos/client/echo';
|
|
12
|
+
import { Obj, Type } from '@dxos/echo';
|
|
13
|
+
|
|
14
|
+
import { createClient, createClientContextProvider } from '../testing/util';
|
|
15
|
+
|
|
16
|
+
import { useQuery } from './useQuery';
|
|
17
|
+
|
|
18
|
+
describe('useQuery', () => {
|
|
19
|
+
// TODO(dmaretskyi): Fix this test.
|
|
20
|
+
test.skip('deleting an element should result in correct render sequence', async () => {
|
|
21
|
+
// Setup: Create client and space.
|
|
22
|
+
const { client, space } = await createClient({ createIdentity: true, createSpace: true });
|
|
23
|
+
const wrapper = await createClientContextProvider(client);
|
|
24
|
+
|
|
25
|
+
// Create 3 test objects: Alice, Bob, Charlie.
|
|
26
|
+
const alice = Obj.make(Type.Expando, { name: 'Alice' });
|
|
27
|
+
const bob = Obj.make(Type.Expando, { name: 'Bob' });
|
|
28
|
+
const charlie = Obj.make(Type.Expando, { name: 'Charlie' });
|
|
29
|
+
|
|
30
|
+
space!.db.add(alice);
|
|
31
|
+
space!.db.add(bob);
|
|
32
|
+
space!.db.add(charlie);
|
|
33
|
+
await space!.db.flush();
|
|
34
|
+
|
|
35
|
+
// Track all renders to observe the bug.
|
|
36
|
+
const allRenders: string[][] = [];
|
|
37
|
+
|
|
38
|
+
// Setup useQuery hook that captures every render.
|
|
39
|
+
renderHook(
|
|
40
|
+
() => {
|
|
41
|
+
const objects = useQuery(space?.db, Filter.type(Type.Expando));
|
|
42
|
+
|
|
43
|
+
// Capture the names in this render.
|
|
44
|
+
const namesInThisRender = objects.map((obj) => obj.name);
|
|
45
|
+
allRenders.push([...namesInThisRender]);
|
|
46
|
+
|
|
47
|
+
return objects;
|
|
48
|
+
},
|
|
49
|
+
{ wrapper },
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// Wait for initial renders to complete.
|
|
53
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
54
|
+
|
|
55
|
+
// THE BUG REPRODUCTION: Delete Bob.
|
|
56
|
+
space!.db.remove(bob);
|
|
57
|
+
|
|
58
|
+
// Wait for all reactive updates to complete.
|
|
59
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
60
|
+
|
|
61
|
+
// TODO(ZaymonFC): Remove this comment once the flash bug is resolved.
|
|
62
|
+
// NOTE(ZaymonFC):
|
|
63
|
+
// Expected: 3 renders
|
|
64
|
+
// 1. [] (empty)
|
|
65
|
+
// 2. ['Alice', 'Bob', 'Charlie'] (all loaded)
|
|
66
|
+
// 3. ['Alice', 'Charlie'] (Bob removed, no flash)
|
|
67
|
+
|
|
68
|
+
// Actual: 4 renders
|
|
69
|
+
// 1. [] (empty)
|
|
70
|
+
// 2. ['Alice', 'Bob', 'Charlie'] (all loaded)
|
|
71
|
+
// 3. ['Alice', 'Charlie', 'Bob'] (FLASH BUG - Bob moves to end!)x
|
|
72
|
+
// 4. ['Alice', 'Charlie'] (Bob finally removed)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
expect(allRenders).toEqual([
|
|
76
|
+
[], // Initial loading state.
|
|
77
|
+
['Alice', 'Bob', 'Charlie'], // All objects loaded.
|
|
78
|
+
['Alice', 'Charlie'], // Bob removed (no flash).
|
|
79
|
+
]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test.skip('bulk deleting multiple items should remove them from query results', async () => {
|
|
83
|
+
// Setup: Create client and space.
|
|
84
|
+
const { client, space } = await createClient({ createIdentity: true, createSpace: true });
|
|
85
|
+
const wrapper = await createClientContextProvider(client);
|
|
86
|
+
|
|
87
|
+
// Create 10 test objects: 1, 2, 3, ..., 10.
|
|
88
|
+
const objects = Array.from({ length: 10 }, (_, i) => Obj.make(Type.Expando, { value: i + 1 }));
|
|
89
|
+
|
|
90
|
+
objects.forEach((obj) => space!.db.add(obj));
|
|
91
|
+
await space!.db.flush();
|
|
92
|
+
|
|
93
|
+
// Track all renders to observe the bug.
|
|
94
|
+
const allRenders: number[][] = [];
|
|
95
|
+
|
|
96
|
+
// Setup useQuery hook that captures every render.
|
|
97
|
+
renderHook(
|
|
98
|
+
() => {
|
|
99
|
+
const queryObjects = useQuery(space?.db, Filter.type(Type.Expando));
|
|
100
|
+
|
|
101
|
+
// Capture the values in this render.
|
|
102
|
+
const valuesInThisRender = queryObjects.map((obj) => obj.value);
|
|
103
|
+
allRenders.push([...valuesInThisRender]);
|
|
104
|
+
|
|
105
|
+
return queryObjects;
|
|
106
|
+
},
|
|
107
|
+
{ wrapper },
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// Wait for initial renders to complete.
|
|
111
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
112
|
+
|
|
113
|
+
// THE BUG REPRODUCTION: Delete all items in a loop.
|
|
114
|
+
for (const item of objects) {
|
|
115
|
+
space!.db.remove(item);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Wait for all reactive updates to complete.
|
|
119
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
120
|
+
|
|
121
|
+
// Convert to sets for order-independent comparison.
|
|
122
|
+
const renderSets = allRenders.map((render) => new Set(render));
|
|
123
|
+
|
|
124
|
+
expect(renderSets).toEqual([
|
|
125
|
+
new Set([]), // Initial loading state.
|
|
126
|
+
new Set([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]), // All objects loaded.
|
|
127
|
+
// TODO(dmaretskyi): Fix this with query ordering.
|
|
128
|
+
new Set([]), // All items deleted.
|
|
129
|
+
]);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
*/
|
package/src/useQuery.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2022 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { useMemo, useSyncExternalStore } from 'react';
|
|
6
|
+
|
|
7
|
+
import { type Database, type Entity, Filter, Query } from '@dxos/echo';
|
|
8
|
+
|
|
9
|
+
const EMPTY_ARRAY: never[] = [];
|
|
10
|
+
|
|
11
|
+
const noop = () => {};
|
|
12
|
+
|
|
13
|
+
interface UseQueryFn {
|
|
14
|
+
<Q extends Query.Any, O extends Entity.Entity<Query.Type<Q>> = Entity.Entity<Query.Type<Q>>>(
|
|
15
|
+
resource: Database.Queryable | undefined,
|
|
16
|
+
query: Q,
|
|
17
|
+
): O[];
|
|
18
|
+
|
|
19
|
+
<F extends Filter.Any, O extends Entity.Entity<Filter.Type<F>> = Entity.Entity<Filter.Type<F>>>(
|
|
20
|
+
resource: Database.Queryable | undefined,
|
|
21
|
+
filter: F,
|
|
22
|
+
): O[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Create subscription.
|
|
27
|
+
*/
|
|
28
|
+
export const useQuery: UseQueryFn = (
|
|
29
|
+
resource: Database.Queryable | undefined,
|
|
30
|
+
queryOrFilter: Query.Any | Filter.Any,
|
|
31
|
+
): Entity.Any[] => {
|
|
32
|
+
const query = Filter.is(queryOrFilter) ? Query.select(queryOrFilter) : queryOrFilter;
|
|
33
|
+
|
|
34
|
+
const { getObjects, subscribe } = useMemo(() => {
|
|
35
|
+
let queryResult = undefined;
|
|
36
|
+
if (resource) {
|
|
37
|
+
queryResult = resource.query(query);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let subscribed = false;
|
|
41
|
+
return {
|
|
42
|
+
getObjects: () => (subscribed && queryResult ? queryResult.results : EMPTY_ARRAY),
|
|
43
|
+
subscribe: (cb: () => void) => {
|
|
44
|
+
subscribed = true;
|
|
45
|
+
const unsubscribe = queryResult?.subscribe(cb) ?? noop;
|
|
46
|
+
return () => {
|
|
47
|
+
unsubscribe?.();
|
|
48
|
+
subscribed = false;
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}, [resource, JSON.stringify(query.ast)]);
|
|
53
|
+
|
|
54
|
+
// https://beta.reactjs.org/reference/react/useSyncExternalStore
|
|
55
|
+
// NOTE: This hook will resubscribe whenever the callback passed to the first argument changes; make sure it is stable.
|
|
56
|
+
const objects = useSyncExternalStore<Entity.Any[]>(subscribe, getObjects);
|
|
57
|
+
return objects;
|
|
58
|
+
};
|
package/src/useSchema.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { useMemo, useSyncExternalStore } from 'react';
|
|
6
|
+
|
|
7
|
+
import { type Database, type Type } from '@dxos/echo';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Subscribe to and retrieve schema changes from a space's schema registry.
|
|
11
|
+
*/
|
|
12
|
+
export const useSchema = <T extends Type.Entity.Any = Type.Entity.Any>(
|
|
13
|
+
db?: Database.Database,
|
|
14
|
+
typename?: string,
|
|
15
|
+
): T | undefined => {
|
|
16
|
+
const { subscribe, getSchema } = useMemo(() => {
|
|
17
|
+
if (!typename || !db) {
|
|
18
|
+
return {
|
|
19
|
+
subscribe: () => () => {},
|
|
20
|
+
getSchema: () => undefined,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const query = db.schemaRegistry.query({ typename, location: ['database', 'runtime'] });
|
|
25
|
+
const initialResult = query.runSync()[0];
|
|
26
|
+
let currentSchema = initialResult;
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
subscribe: (onStoreChange: () => void) => {
|
|
30
|
+
const unsubscribe = query.subscribe(() => {
|
|
31
|
+
currentSchema = query.results[0];
|
|
32
|
+
onStoreChange();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
return unsubscribe;
|
|
36
|
+
},
|
|
37
|
+
getSchema: () => currentSchema,
|
|
38
|
+
};
|
|
39
|
+
}, [typename, db]);
|
|
40
|
+
|
|
41
|
+
return useSyncExternalStore(subscribe, getSchema) as T;
|
|
42
|
+
};
|