@dxos/echo-react 0.10.0 → 0.11.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/dist/lib/index.mjs +329 -0
- package/dist/lib/index.mjs.map +1 -0
- package/dist/types/src/index.d.ts +1 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/useObject.d.ts +9 -0
- package/dist/types/src/useObject.d.ts.map +1 -1
- package/dist/types/src/usePagination.d.ts +56 -0
- package/dist/types/src/usePagination.d.ts.map +1 -0
- package/dist/types/src/usePagination.test.d.ts +2 -0
- package/dist/types/src/usePagination.test.d.ts.map +1 -0
- package/dist/types/src/useQuery.d.ts +1 -0
- package/dist/types/src/useQuery.d.ts.map +1 -1
- package/dist/types/src/useQuery.test.d.ts +2 -0
- package/dist/types/src/useQuery.test.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -4
- package/src/index.ts +1 -0
- package/src/useObject.ts +25 -20
- package/src/usePagination.test.tsx +572 -0
- package/src/usePagination.ts +276 -0
- package/src/useQuery.test.tsx +106 -0
- package/src/useQuery.ts +10 -2
- package/dist/lib/neutral/index.mjs +0 -194
- package/dist/lib/neutral/index.mjs.map +0 -7
- package/dist/lib/neutral/meta.json +0 -1
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { renderHook, waitFor } from '@testing-library/react';
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { Aggregate, Database, Feed, Filter, Obj, Order, Query } from '@dxos/echo';
|
|
9
|
+
import { EchoTestBuilder } from '@dxos/echo-client/testing';
|
|
10
|
+
import { TestSchema } from '@dxos/echo/testing';
|
|
11
|
+
|
|
12
|
+
import { usePagination } from './usePagination';
|
|
13
|
+
|
|
14
|
+
describe('usePagination', () => {
|
|
15
|
+
let builder: EchoTestBuilder;
|
|
16
|
+
|
|
17
|
+
beforeEach(async () => {
|
|
18
|
+
builder = await new EchoTestBuilder().open();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
await builder.close();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const appendPeople = async (feed: Feed.Feed, db: Database.Database, count: number) => {
|
|
26
|
+
for (let i = 0; i < count; i++) {
|
|
27
|
+
await db.appendToFeed(feed, [Obj.make(TestSchema.Person, { name: `person-${i}` })]);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
test('returns the newest page-size items, newest first', async () => {
|
|
32
|
+
await using peer = await builder.createPeer({ types: [Feed.Feed, TestSchema.Person] });
|
|
33
|
+
const db = await peer.createDatabase();
|
|
34
|
+
const feed = db.add(Feed.make({ name: 'windowed' }));
|
|
35
|
+
await appendPeople(feed, db, 10);
|
|
36
|
+
|
|
37
|
+
const query = Query.select(Filter.type(TestSchema.Person)).from(feed).orderBy(Order.natural('desc')).limit(3);
|
|
38
|
+
const { result } = renderHook(() => usePagination(db, query));
|
|
39
|
+
|
|
40
|
+
await waitFor(() => {
|
|
41
|
+
expect(result.current.items.map((person) => person.name)).toEqual(['person-9', 'person-8', 'person-7']);
|
|
42
|
+
});
|
|
43
|
+
expect(result.current.hasMore).toBe(true);
|
|
44
|
+
expect(result.current.atHead).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('getNext extends the window toward older items', async () => {
|
|
48
|
+
await using peer = await builder.createPeer({ types: [Feed.Feed, TestSchema.Person] });
|
|
49
|
+
const db = await peer.createDatabase();
|
|
50
|
+
const feed = db.add(Feed.make({ name: 'windowed' }));
|
|
51
|
+
await appendPeople(feed, db, 10);
|
|
52
|
+
|
|
53
|
+
const query = Query.select(Filter.type(TestSchema.Person)).from(feed).orderBy(Order.natural('desc')).limit(3);
|
|
54
|
+
const { result } = renderHook(() => usePagination(db, query));
|
|
55
|
+
|
|
56
|
+
await waitFor(() => {
|
|
57
|
+
expect(result.current.items).toHaveLength(3);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
result.current.getNext();
|
|
61
|
+
|
|
62
|
+
await waitFor(() => {
|
|
63
|
+
expect(result.current.items.map((person) => person.name)).toEqual([
|
|
64
|
+
'person-9',
|
|
65
|
+
'person-8',
|
|
66
|
+
'person-7',
|
|
67
|
+
'person-6',
|
|
68
|
+
'person-5',
|
|
69
|
+
'person-4',
|
|
70
|
+
]);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('a burst of synchronous getNext calls in the same tick only advances by one page', async () => {
|
|
75
|
+
// Regression test: a virtualizer's `onChange` can fire multiple times for a single "scrolled
|
|
76
|
+
// near the edge" event (once per newly-rendered row) before React re-renders with the new
|
|
77
|
+
// range. `getNext`'s single-flight guard must hold across that whole burst, not just across
|
|
78
|
+
// renders -- otherwise the window jumps by (burst size) pages instead of one.
|
|
79
|
+
await using peer = await builder.createPeer({ types: [Feed.Feed, TestSchema.Person] });
|
|
80
|
+
const db = await peer.createDatabase();
|
|
81
|
+
const feed = db.add(Feed.make({ name: 'windowed' }));
|
|
82
|
+
await appendPeople(feed, db, 30);
|
|
83
|
+
|
|
84
|
+
const query = Query.select(Filter.type(TestSchema.Person)).from(feed).orderBy(Order.natural('desc')).limit(3);
|
|
85
|
+
const { result } = renderHook(() => usePagination(db, query));
|
|
86
|
+
|
|
87
|
+
await waitFor(() => {
|
|
88
|
+
expect(result.current.items).toHaveLength(3);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Simulate the burst: call getNext synchronously many times before any re-render happens.
|
|
92
|
+
for (let i = 0; i < 10; i++) {
|
|
93
|
+
result.current.getNext();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
await waitFor(() => {
|
|
97
|
+
// Exactly one page's worth of growth (3 + 3 = 6), not ten (3 + 10*3 = 33).
|
|
98
|
+
expect(result.current.items).toHaveLength(6);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Give any erroneous extra state updates a chance to land, then confirm it's still 6.
|
|
102
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
103
|
+
expect(result.current.items).toHaveLength(6);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('hasMore becomes false once the start of the feed is loaded', async () => {
|
|
107
|
+
await using peer = await builder.createPeer({ types: [Feed.Feed, TestSchema.Person] });
|
|
108
|
+
const db = await peer.createDatabase();
|
|
109
|
+
const feed = db.add(Feed.make({ name: 'windowed' }));
|
|
110
|
+
await appendPeople(feed, db, 5);
|
|
111
|
+
|
|
112
|
+
const query = Query.select(Filter.type(TestSchema.Person)).from(feed).orderBy(Order.natural('desc')).limit(3);
|
|
113
|
+
const { result } = renderHook(() => usePagination(db, query));
|
|
114
|
+
|
|
115
|
+
await waitFor(() => {
|
|
116
|
+
expect(result.current.items).toHaveLength(3);
|
|
117
|
+
});
|
|
118
|
+
expect(result.current.hasMore).toBe(true);
|
|
119
|
+
|
|
120
|
+
result.current.getNext();
|
|
121
|
+
|
|
122
|
+
await waitFor(() => {
|
|
123
|
+
expect(result.current.items).toHaveLength(5);
|
|
124
|
+
});
|
|
125
|
+
expect(result.current.hasMore).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('growing past maxWindowSize evicts the newest items and detaches from the head', async () => {
|
|
129
|
+
await using peer = await builder.createPeer({ types: [Feed.Feed, TestSchema.Person] });
|
|
130
|
+
const db = await peer.createDatabase();
|
|
131
|
+
const feed = db.add(Feed.make({ name: 'windowed' }));
|
|
132
|
+
await appendPeople(feed, db, 20);
|
|
133
|
+
|
|
134
|
+
const query = Query.select(Filter.type(TestSchema.Person)).from(feed).orderBy(Order.natural('desc')).limit(5);
|
|
135
|
+
const { result } = renderHook(() => usePagination(db, query, { maxWindowSize: 10 }));
|
|
136
|
+
|
|
137
|
+
await waitFor(() => {
|
|
138
|
+
expect(result.current.items).toHaveLength(5);
|
|
139
|
+
});
|
|
140
|
+
expect(result.current.atHead).toBe(true);
|
|
141
|
+
|
|
142
|
+
result.current.getNext(); // limit -> 10 (within maxWindowSize)
|
|
143
|
+
await waitFor(() => expect(result.current.items).toHaveLength(10));
|
|
144
|
+
expect(result.current.atHead).toBe(true);
|
|
145
|
+
|
|
146
|
+
result.current.getNext(); // would exceed maxWindowSize -> skip grows instead, limit stays at 10
|
|
147
|
+
await waitFor(() => {
|
|
148
|
+
expect(result.current.items).toHaveLength(10);
|
|
149
|
+
expect(result.current.atHead).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
// Still bounded, and now shows older items (window slid past the newest 5).
|
|
152
|
+
expect(result.current.items.map((person) => person.name)).toEqual([
|
|
153
|
+
'person-14',
|
|
154
|
+
'person-13',
|
|
155
|
+
'person-12',
|
|
156
|
+
'person-11',
|
|
157
|
+
'person-10',
|
|
158
|
+
'person-9',
|
|
159
|
+
'person-8',
|
|
160
|
+
'person-7',
|
|
161
|
+
'person-6',
|
|
162
|
+
'person-5',
|
|
163
|
+
]);
|
|
164
|
+
|
|
165
|
+
result.current.jumpToHead();
|
|
166
|
+
await waitFor(() => {
|
|
167
|
+
expect(result.current.atHead).toBe(true);
|
|
168
|
+
expect(result.current.items.map((person) => person.name)).toEqual([
|
|
169
|
+
'person-19',
|
|
170
|
+
'person-18',
|
|
171
|
+
'person-17',
|
|
172
|
+
'person-16',
|
|
173
|
+
'person-15',
|
|
174
|
+
]);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('getNext past maxWindowSize does not transiently undershoot the window size', async () => {
|
|
179
|
+
// Advancing `skip` (e.g. `{skip:0,limit:10}` -> `{skip:5,limit:10}`) must never render fewer
|
|
180
|
+
// than `limit` items in between: the new range's query result can only overwrite the previous
|
|
181
|
+
// range's once it has actually resolved, otherwise the virtualizer sees a transient shrink.
|
|
182
|
+
await using peer = await builder.createPeer({ types: [Feed.Feed, TestSchema.Person] });
|
|
183
|
+
const db = await peer.createDatabase();
|
|
184
|
+
const feed = db.add(Feed.make({ name: 'windowed' }));
|
|
185
|
+
await appendPeople(feed, db, 20);
|
|
186
|
+
|
|
187
|
+
const query = Query.select(Filter.type(TestSchema.Person)).from(feed).orderBy(Order.natural('desc')).limit(5);
|
|
188
|
+
const lengths: number[] = [];
|
|
189
|
+
const { result } = renderHook(() => {
|
|
190
|
+
const paginated = usePagination(db, query, { maxWindowSize: 10 });
|
|
191
|
+
lengths.push(paginated.items.length);
|
|
192
|
+
return paginated;
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
await waitFor(() => expect(result.current.items).toHaveLength(5));
|
|
196
|
+
result.current.getNext(); // limit -> 10 (within maxWindowSize)
|
|
197
|
+
await waitFor(() => expect(result.current.items).toHaveLength(10));
|
|
198
|
+
lengths.length = 0;
|
|
199
|
+
|
|
200
|
+
result.current.getNext(); // slides skip 0 -> 5, evicting the newest 5 -- the undershoot path
|
|
201
|
+
await waitFor(() => {
|
|
202
|
+
expect(result.current.items).toHaveLength(10);
|
|
203
|
+
expect(result.current.atHead).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
expect(Math.min(...lengths)).toBe(10);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('getPrevious slides the window back toward the head after it has advanced', async () => {
|
|
210
|
+
await using peer = await builder.createPeer({ types: [Feed.Feed, TestSchema.Person] });
|
|
211
|
+
const db = await peer.createDatabase();
|
|
212
|
+
const feed = db.add(Feed.make({ name: 'windowed' }));
|
|
213
|
+
await appendPeople(feed, db, 30);
|
|
214
|
+
|
|
215
|
+
const query = Query.select(Filter.type(TestSchema.Person)).from(feed).orderBy(Order.natural('desc')).limit(5);
|
|
216
|
+
const { result } = renderHook(() => usePagination(db, query, { maxWindowSize: 10 }));
|
|
217
|
+
|
|
218
|
+
await waitFor(() => expect(result.current.items).toHaveLength(5));
|
|
219
|
+
|
|
220
|
+
result.current.getNext(); // limit -> 10 (within maxWindowSize)
|
|
221
|
+
await waitFor(() => expect(result.current.items).toHaveLength(10));
|
|
222
|
+
|
|
223
|
+
result.current.getNext(); // exceeds maxWindowSize -> slides forward (skip: 0 -> 5)
|
|
224
|
+
await waitFor(() => {
|
|
225
|
+
expect(result.current.atHead).toBe(false);
|
|
226
|
+
expect(result.current.items.map((person) => person.name)).toEqual([
|
|
227
|
+
'person-24',
|
|
228
|
+
'person-23',
|
|
229
|
+
'person-22',
|
|
230
|
+
'person-21',
|
|
231
|
+
'person-20',
|
|
232
|
+
'person-19',
|
|
233
|
+
'person-18',
|
|
234
|
+
'person-17',
|
|
235
|
+
'person-16',
|
|
236
|
+
'person-15',
|
|
237
|
+
]);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// getPrevious is the inverse of the slide above: it should land exactly back on the head.
|
|
241
|
+
result.current.getPrevious();
|
|
242
|
+
await waitFor(() => {
|
|
243
|
+
expect(result.current.atHead).toBe(true);
|
|
244
|
+
expect(result.current.items.map((person) => person.name)).toEqual([
|
|
245
|
+
'person-29',
|
|
246
|
+
'person-28',
|
|
247
|
+
'person-27',
|
|
248
|
+
'person-26',
|
|
249
|
+
'person-25',
|
|
250
|
+
'person-24',
|
|
251
|
+
'person-23',
|
|
252
|
+
'person-22',
|
|
253
|
+
'person-21',
|
|
254
|
+
'person-20',
|
|
255
|
+
]);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// Already at the head -- no-op.
|
|
259
|
+
result.current.getPrevious();
|
|
260
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
261
|
+
expect(result.current.atHead).toBe(true);
|
|
262
|
+
expect(result.current.items).toHaveLength(10);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test('items never regresses to empty across a getNext transition (no scroll-jump regression)', async () => {
|
|
266
|
+
// Every `getNext` produces a brand new query AST (a new skip/limit); `items` must keep showing
|
|
267
|
+
// the previous range's results until the new range's first real result arrives, since a flash
|
|
268
|
+
// to `[]` on every page load collapses the virtualizer and snaps scroll position to the top.
|
|
269
|
+
await using peer = await builder.createPeer({ types: [Feed.Feed, TestSchema.Person] });
|
|
270
|
+
const db = await peer.createDatabase();
|
|
271
|
+
const feed = db.add(Feed.make({ name: 'windowed' }));
|
|
272
|
+
await appendPeople(feed, db, 30);
|
|
273
|
+
|
|
274
|
+
const query = Query.select(Filter.type(TestSchema.Person)).from(feed).orderBy(Order.natural('desc')).limit(3);
|
|
275
|
+
const lengths: number[] = [];
|
|
276
|
+
const { result } = renderHook(() => {
|
|
277
|
+
const paginated = usePagination(db, query);
|
|
278
|
+
lengths.push(paginated.items.length);
|
|
279
|
+
return paginated;
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
await waitFor(() => expect(result.current.items).toHaveLength(3));
|
|
283
|
+
lengths.length = 0;
|
|
284
|
+
|
|
285
|
+
result.current.getNext();
|
|
286
|
+
await waitFor(() => expect(result.current.items).toHaveLength(6));
|
|
287
|
+
|
|
288
|
+
expect(lengths.every((length) => length > 0)).toBe(true);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('reflects live appends while at the head', async () => {
|
|
292
|
+
await using peer = await builder.createPeer({ types: [Feed.Feed, TestSchema.Person] });
|
|
293
|
+
const db = await peer.createDatabase();
|
|
294
|
+
const feed = db.add(Feed.make({ name: 'windowed' }));
|
|
295
|
+
await appendPeople(feed, db, 2);
|
|
296
|
+
|
|
297
|
+
const query = Query.select(Filter.type(TestSchema.Person)).from(feed).orderBy(Order.natural('desc')).limit(5);
|
|
298
|
+
const { result } = renderHook(() => usePagination(db, query));
|
|
299
|
+
|
|
300
|
+
await waitFor(() => {
|
|
301
|
+
expect(result.current.items.map((person) => person.name)).toEqual(['person-1', 'person-0']);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
await db.appendToFeed(feed, [Obj.make(TestSchema.Person, { name: 'person-2' })]);
|
|
305
|
+
|
|
306
|
+
await waitFor(() => {
|
|
307
|
+
expect(result.current.items.map((person) => person.name)).toEqual(['person-2', 'person-1', 'person-0']);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('holds the previous page across a query-identity change instead of blanking', async () => {
|
|
312
|
+
// Regression test for the mailbox "flash to loading during sync" bug. The Inbox/Sent view
|
|
313
|
+
// selects by an explicit id set (`Filter.id(...ids)`); each message tagged mid-sync grows that
|
|
314
|
+
// set, which is a new query identity and rebuilds the internal store. The rebuilt store must
|
|
315
|
+
// seed from the page already on screen -- keeping it visible with `isLoading` false -- rather
|
|
316
|
+
// than resetting to empty + loading and blanking the whole list on every sync batch.
|
|
317
|
+
await using peer = await builder.createPeer({ types: [Feed.Feed, TestSchema.Person] });
|
|
318
|
+
const db = await peer.createDatabase();
|
|
319
|
+
const feed = db.add(Feed.make({ name: 'tagged' }));
|
|
320
|
+
await appendPeople(feed, db, 5);
|
|
321
|
+
|
|
322
|
+
// Resolve the feed's object ids (newest-first) to build a growing id-set filter, as the mailbox
|
|
323
|
+
// system-tag view does from its `TagIndex`.
|
|
324
|
+
const all = await db
|
|
325
|
+
.query(Query.select(Filter.type(TestSchema.Person)).from(feed).orderBy(Order.natural('desc')))
|
|
326
|
+
.run();
|
|
327
|
+
const ids = all.map((person) => person.id);
|
|
328
|
+
|
|
329
|
+
const queryFor = (selected: string[]) =>
|
|
330
|
+
Query.select(Filter.and(Filter.type(TestSchema.Person), Filter.id(...selected)))
|
|
331
|
+
.from(feed)
|
|
332
|
+
.orderBy(Order.natural('desc'))
|
|
333
|
+
.limit(10);
|
|
334
|
+
|
|
335
|
+
const { result, rerender } = renderHook(({ query }) => usePagination(db, query), {
|
|
336
|
+
initialProps: { query: queryFor(ids.slice(0, 3)) },
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
await waitFor(() => expect(result.current.items).toHaveLength(3));
|
|
340
|
+
expect(result.current.isLoading).toBe(false);
|
|
341
|
+
|
|
342
|
+
// A new message gets tagged: the id set grows -> new query identity -> internal store rebuild.
|
|
343
|
+
rerender({ query: queryFor(ids.slice(0, 5)) });
|
|
344
|
+
|
|
345
|
+
// The previously-shown page stays on screen (never empties) and no loading state is surfaced for
|
|
346
|
+
// this background refresh -- the whole point of the fix. (Pre-fix: items [] and isLoading true.)
|
|
347
|
+
expect(result.current.items.length).toBeGreaterThan(0);
|
|
348
|
+
expect(result.current.isLoading).toBe(false);
|
|
349
|
+
|
|
350
|
+
await waitFor(() => expect(result.current.items).toHaveLength(5));
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test('pages over whole groups for a grouped query', async () => {
|
|
354
|
+
await using peer = await builder.createPeer({ types: [Feed.Feed, TestSchema.Person] });
|
|
355
|
+
const db = await peer.createDatabase();
|
|
356
|
+
const feed = db.add(Feed.make({ name: 'grouped' }));
|
|
357
|
+
// 8 people over 4 emails, appended round-robin so group members interleave in the stream.
|
|
358
|
+
for (let i = 0; i < 8; i++) {
|
|
359
|
+
await db.appendToFeed(feed, [
|
|
360
|
+
Obj.make(TestSchema.Person, { name: `person-${i}`, email: `group-${i % 4}@example.com` }),
|
|
361
|
+
]);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const query = Query.select(Filter.type(TestSchema.Person))
|
|
365
|
+
.from(feed)
|
|
366
|
+
.orderBy(Order.natural('desc'))
|
|
367
|
+
.aggregate({ email: Aggregate.group('email'), items: Aggregate.items() })
|
|
368
|
+
.limit(2);
|
|
369
|
+
const { result } = renderHook(() => usePagination(db, query));
|
|
370
|
+
|
|
371
|
+
// Groups are ordered by first occurrence in the newest-first stream; the page size counts
|
|
372
|
+
// groups, not rows.
|
|
373
|
+
await waitFor(() => {
|
|
374
|
+
expect(result.current.items.map((group) => group.email)).toEqual(['group-3@example.com', 'group-2@example.com']);
|
|
375
|
+
});
|
|
376
|
+
expect(result.current.items.map((group) => group.items.map((person) => person.name))).toEqual([
|
|
377
|
+
['person-7', 'person-3'],
|
|
378
|
+
['person-6', 'person-2'],
|
|
379
|
+
]);
|
|
380
|
+
expect(result.current.hasMore).toBe(true);
|
|
381
|
+
|
|
382
|
+
result.current.getNext();
|
|
383
|
+
|
|
384
|
+
await waitFor(() => {
|
|
385
|
+
expect(result.current.items.map((group) => group.email)).toEqual([
|
|
386
|
+
'group-3@example.com',
|
|
387
|
+
'group-2@example.com',
|
|
388
|
+
'group-1@example.com',
|
|
389
|
+
'group-0@example.com',
|
|
390
|
+
]);
|
|
391
|
+
});
|
|
392
|
+
expect(result.current.items.map((group) => group.items.map((person) => person.name))).toEqual([
|
|
393
|
+
['person-7', 'person-3'],
|
|
394
|
+
['person-6', 'person-2'],
|
|
395
|
+
['person-5', 'person-1'],
|
|
396
|
+
['person-4', 'person-0'],
|
|
397
|
+
]);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test('pages over whole groups produced by a subquery semi-join (Filter.in(query.project(...)))', async () => {
|
|
401
|
+
await using peer = await builder.createPeer({ types: [Feed.Feed, TestSchema.Task] });
|
|
402
|
+
const db = await peer.createDatabase();
|
|
403
|
+
const feed = db.add(Feed.make({ name: 'semi-join' }));
|
|
404
|
+
|
|
405
|
+
// Three "threads" by title; only threads with an explicitly completed member qualify.
|
|
406
|
+
await db.appendToFeed(feed, [
|
|
407
|
+
Obj.make(TestSchema.Task, { title: 'a', completed: true }),
|
|
408
|
+
Obj.make(TestSchema.Task, { title: 'a', completed: false }),
|
|
409
|
+
Obj.make(TestSchema.Task, { title: 'b', completed: true }),
|
|
410
|
+
Obj.make(TestSchema.Task, { title: 'b', completed: false }),
|
|
411
|
+
Obj.make(TestSchema.Task, { title: 'c', completed: false }), // no qualifying member — excluded
|
|
412
|
+
]);
|
|
413
|
+
|
|
414
|
+
const matches = Query.select(Filter.type(TestSchema.Task, { completed: true })).from(feed);
|
|
415
|
+
const source = Query.select(Filter.type(TestSchema.Task, { title: Filter.in(matches.project('title')) }))
|
|
416
|
+
.from(feed)
|
|
417
|
+
.orderBy(Order.natural('asc'))
|
|
418
|
+
.aggregate({ title: Aggregate.group('title'), items: Aggregate.items() })
|
|
419
|
+
.limit(1);
|
|
420
|
+
const { result } = renderHook(() => usePagination(db, source));
|
|
421
|
+
|
|
422
|
+
// First page: one whole qualifying thread (both members), not a partial row slice.
|
|
423
|
+
await waitFor(() => {
|
|
424
|
+
expect(result.current.items).toHaveLength(1);
|
|
425
|
+
});
|
|
426
|
+
expect(result.current.items[0].items).toHaveLength(2);
|
|
427
|
+
expect(result.current.hasMore).toBe(true);
|
|
428
|
+
|
|
429
|
+
result.current.getNext();
|
|
430
|
+
|
|
431
|
+
// Second page reveals the other qualifying thread; the non-qualifying thread 'c' never appears.
|
|
432
|
+
await waitFor(() => {
|
|
433
|
+
expect(result.current.items.map((group) => group.title).sort()).toEqual(['a', 'b']);
|
|
434
|
+
});
|
|
435
|
+
expect(result.current.items.every((group) => group.items.length === 2)).toBe(true);
|
|
436
|
+
expect(result.current.items.some((group) => group.title === 'c')).toBe(false);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test('orders and pages groups by a max aggregate in both directions', async () => {
|
|
440
|
+
await using peer = await builder.createPeer({ types: [Feed.Feed, TestSchema.Person] });
|
|
441
|
+
const db = await peer.createDatabase();
|
|
442
|
+
const feed = db.add(Feed.make({ name: 'aggregated' }));
|
|
443
|
+
// Two members per email; each group's max(name) is the recency proxy. Appended so that neither
|
|
444
|
+
// feed order nor first-occurrence matches aggregate order — only max(name) ordering does.
|
|
445
|
+
const emailByName: Record<string, string> = {
|
|
446
|
+
p0: 'd@x',
|
|
447
|
+
p1: 'a@x',
|
|
448
|
+
p2: 'b@x',
|
|
449
|
+
p3: 'c@x',
|
|
450
|
+
p4: 'd@x',
|
|
451
|
+
p5: 'c@x',
|
|
452
|
+
p6: 'b@x',
|
|
453
|
+
p7: 'a@x',
|
|
454
|
+
};
|
|
455
|
+
for (const name of ['p3', 'p0', 'p6', 'p1', 'p4', 'p7', 'p2', 'p5']) {
|
|
456
|
+
await db.appendToFeed(feed, [Obj.make(TestSchema.Person, { name, username: name, email: emailByName[name] })]);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Members newest-first (name desc) within each thread; threads ordered by their max(name).
|
|
460
|
+
const grouped = Query.select(Filter.type(TestSchema.Person))
|
|
461
|
+
.from(feed)
|
|
462
|
+
.orderBy(Order.property('name', 'desc'))
|
|
463
|
+
.aggregate({ email: Aggregate.group('email'), latest: Aggregate.max('name'), items: Aggregate.items() });
|
|
464
|
+
|
|
465
|
+
const descending = renderHook(() => usePagination(db, grouped.orderBy(Order.property('latest', 'desc')).limit(2)));
|
|
466
|
+
// First page: the two groups with the highest max(name) — a@x (p7) then b@x (p6).
|
|
467
|
+
await waitFor(() => {
|
|
468
|
+
expect(descending.result.current.items.map((group) => group.email)).toEqual(['a@x', 'b@x']);
|
|
469
|
+
});
|
|
470
|
+
expect(descending.result.current.items.map((group) => group.latest)).toEqual(['p7', 'p6']);
|
|
471
|
+
expect(descending.result.current.items.map((group) => group.items.map((person) => person.name))).toEqual([
|
|
472
|
+
['p7', 'p1'],
|
|
473
|
+
['p6', 'p2'],
|
|
474
|
+
]);
|
|
475
|
+
|
|
476
|
+
descending.result.current.getNext();
|
|
477
|
+
await waitFor(() => {
|
|
478
|
+
expect(descending.result.current.items.map((group) => group.email)).toEqual(['a@x', 'b@x', 'c@x', 'd@x']);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
// Ascending flips the thread order by latest message (not by oldest); members stay newest-first.
|
|
482
|
+
const ascending = renderHook(() => usePagination(db, grouped.orderBy(Order.property('latest', 'asc')).limit(2)));
|
|
483
|
+
await waitFor(() => {
|
|
484
|
+
expect(ascending.result.current.items.map((group) => group.email)).toEqual(['d@x', 'c@x']);
|
|
485
|
+
});
|
|
486
|
+
expect(ascending.result.current.items.map((group) => group.items.map((person) => person.name))).toEqual([
|
|
487
|
+
['p4', 'p0'],
|
|
488
|
+
['p5', 'p3'],
|
|
489
|
+
]);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test('isLoading is true while the first page loads, then false once it settles', async () => {
|
|
493
|
+
await using peer = await builder.createPeer({ types: [Feed.Feed, TestSchema.Person] });
|
|
494
|
+
const db = await peer.createDatabase();
|
|
495
|
+
const feed = db.add(Feed.make({ name: 'windowed' }));
|
|
496
|
+
await appendPeople(feed, db, 5);
|
|
497
|
+
|
|
498
|
+
const query = Query.select(Filter.type(TestSchema.Person)).from(feed).orderBy(Order.natural('desc')).limit(3);
|
|
499
|
+
const loadingStates: boolean[] = [];
|
|
500
|
+
const { result } = renderHook(() => {
|
|
501
|
+
const paginated = usePagination(db, query);
|
|
502
|
+
loadingStates.push(paginated.isLoading);
|
|
503
|
+
return paginated;
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
// The async feed query is in flight from the first render until its first result lands.
|
|
507
|
+
expect(loadingStates[0]).toBe(true);
|
|
508
|
+
await waitFor(() => expect(result.current.items).toHaveLength(3));
|
|
509
|
+
expect(result.current.isLoading).toBe(false);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
test('isLoading settles to false for an empty feed (never sticks)', async () => {
|
|
513
|
+
// An empty async feed still delivers a first response, so isLoading must clear on it.
|
|
514
|
+
await using peer = await builder.createPeer({ types: [Feed.Feed, TestSchema.Person] });
|
|
515
|
+
const db = await peer.createDatabase();
|
|
516
|
+
const feed = db.add(Feed.make({ name: 'empty' }));
|
|
517
|
+
|
|
518
|
+
const query = Query.select(Filter.type(TestSchema.Person)).from(feed).orderBy(Order.natural('desc')).limit(3);
|
|
519
|
+
const { result } = renderHook(() => usePagination(db, query));
|
|
520
|
+
|
|
521
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
522
|
+
expect(result.current.items).toHaveLength(0);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test('isLoading goes true again while getNext loads the next page', async () => {
|
|
526
|
+
await using peer = await builder.createPeer({ types: [Feed.Feed, TestSchema.Person] });
|
|
527
|
+
const db = await peer.createDatabase();
|
|
528
|
+
const feed = db.add(Feed.make({ name: 'windowed' }));
|
|
529
|
+
await appendPeople(feed, db, 10);
|
|
530
|
+
|
|
531
|
+
const query = Query.select(Filter.type(TestSchema.Person)).from(feed).orderBy(Order.natural('desc')).limit(3);
|
|
532
|
+
const loadingStates: boolean[] = [];
|
|
533
|
+
const { result } = renderHook(() => {
|
|
534
|
+
const paginated = usePagination(db, query);
|
|
535
|
+
loadingStates.push(paginated.isLoading);
|
|
536
|
+
return paginated;
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
await waitFor(() => expect(result.current.items).toHaveLength(3));
|
|
540
|
+
expect(result.current.isLoading).toBe(false);
|
|
541
|
+
|
|
542
|
+
loadingStates.length = 0;
|
|
543
|
+
result.current.getNext();
|
|
544
|
+
await waitFor(() => expect(result.current.items).toHaveLength(6));
|
|
545
|
+
|
|
546
|
+
// The next range was in flight between the request and its delivery, then settled.
|
|
547
|
+
expect(loadingStates.some((loading) => loading)).toBe(true);
|
|
548
|
+
expect(result.current.isLoading).toBe(false);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test('throws when the query does not carry a limit', async () => {
|
|
552
|
+
await using peer = await builder.createPeer({ types: [Feed.Feed, TestSchema.Person] });
|
|
553
|
+
const db = await peer.createDatabase();
|
|
554
|
+
const feed = db.add(Feed.make({ name: 'windowed' }));
|
|
555
|
+
|
|
556
|
+
const query = Query.select(Filter.type(TestSchema.Person)).from(feed).orderBy(Order.natural('desc'));
|
|
557
|
+
expect(() => renderHook(() => usePagination(db, query))).toThrow(/\.limit\(pageSize\)/);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test('throws when the query already carries a skip', async () => {
|
|
561
|
+
await using peer = await builder.createPeer({ types: [Feed.Feed, TestSchema.Person] });
|
|
562
|
+
const db = await peer.createDatabase();
|
|
563
|
+
const feed = db.add(Feed.make({ name: 'windowed' }));
|
|
564
|
+
|
|
565
|
+
const query = Query.select(Filter.type(TestSchema.Person))
|
|
566
|
+
.from(feed)
|
|
567
|
+
.orderBy(Order.natural('desc'))
|
|
568
|
+
.skip(5)
|
|
569
|
+
.limit(5);
|
|
570
|
+
expect(() => renderHook(() => usePagination(db, query))).toThrow(/manages \.skip\(\)/);
|
|
571
|
+
});
|
|
572
|
+
});
|