@confect/react 10.0.0-next.21 → 10.0.0-next.22
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/CHANGELOG.md +109 -40
- package/dist/PaginatedQueryResult.d.ts +4 -4
- package/dist/PaginatedQueryResult.js.map +1 -1
- package/dist/StreamPagination.d.ts +127 -0
- package/dist/StreamPagination.d.ts.map +1 -0
- package/dist/StreamPagination.js +170 -0
- package/dist/StreamPagination.js.map +1 -0
- package/dist/index.d.ts +31 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +149 -8
- package/dist/index.js.map +1 -1
- package/package.json +12 -12
- package/src/PaginatedQueryResult.ts +4 -4
- package/src/StreamPagination.ts +369 -0
- package/src/index.ts +287 -12
package/package.json
CHANGED
|
@@ -1,27 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@confect/react",
|
|
3
3
|
"description": "Client-side bindings for React apps",
|
|
4
|
-
"version": "10.0.0-next.
|
|
4
|
+
"version": "10.0.0-next.22",
|
|
5
5
|
"author": "RJ Dellecese",
|
|
6
6
|
"bugs": {
|
|
7
7
|
"url": "https://github.com/rjdellecese/confect/issues"
|
|
8
8
|
},
|
|
9
9
|
"devDependencies": {
|
|
10
|
-
"@effect/vitest": "4.0.0-rc.
|
|
10
|
+
"@effect/vitest": "4.0.0-rc.115",
|
|
11
11
|
"@testing-library/react": "16.3.3",
|
|
12
12
|
"@types/node": "24.13.3",
|
|
13
|
-
"@types/react": "19.
|
|
14
|
-
"@types/react-dom": "19.
|
|
13
|
+
"@types/react": "19.3.0",
|
|
14
|
+
"@types/react-dom": "19.3.0",
|
|
15
15
|
"convex": "1.45.0",
|
|
16
|
-
"effect": "4.0.0-rc.
|
|
17
|
-
"happy-dom": "20.
|
|
18
|
-
"react": "19.
|
|
19
|
-
"react-dom": "19.
|
|
20
|
-
"tsdown": "0.
|
|
16
|
+
"effect": "4.0.0-rc.115",
|
|
17
|
+
"happy-dom": "20.14.3",
|
|
18
|
+
"react": "19.3.0",
|
|
19
|
+
"react-dom": "19.3.0",
|
|
20
|
+
"tsdown": "0.23.0",
|
|
21
21
|
"typescript": "7.0.2",
|
|
22
22
|
"unrun": "0.3.1",
|
|
23
23
|
"vite": "8.2.2",
|
|
24
|
-
"vitest": "
|
|
24
|
+
"vitest": "5.0.0"
|
|
25
25
|
},
|
|
26
26
|
"engines": {
|
|
27
27
|
"node": ">=24"
|
|
@@ -56,9 +56,9 @@
|
|
|
56
56
|
"main": "./dist/index.js",
|
|
57
57
|
"module": "./dist/index.js",
|
|
58
58
|
"peerDependencies": {
|
|
59
|
-
"@confect/core": "^10.0.0-next.
|
|
59
|
+
"@confect/core": "^10.0.0-next.22",
|
|
60
60
|
"convex": "^1.36.0",
|
|
61
|
-
"effect": "^4.0.0-rc.
|
|
61
|
+
"effect": "^4.0.0-rc.115",
|
|
62
62
|
"react": "^18.0.0 || ^19.0.0"
|
|
63
63
|
},
|
|
64
64
|
"repository": {
|
|
@@ -12,7 +12,7 @@ type TypeId = typeof TypeId;
|
|
|
12
12
|
* A `PaginatedQueryResult` represents the result of a Confect paginated query.
|
|
13
13
|
* The non-`Failure` variants mirror the statuses of `usePaginatedQuery` from
|
|
14
14
|
* `convex/react` and all carry `results` and `isLoading`, so the common UI
|
|
15
|
-
* shape
|
|
15
|
+
* shape—render the list, show a spinner while loading—needs only an
|
|
16
16
|
* {@link isFailure} early-out and plain field access.
|
|
17
17
|
*
|
|
18
18
|
* Every variant carries `results`, including `Failure`: when a later page
|
|
@@ -23,8 +23,8 @@ type TypeId = typeof TypeId;
|
|
|
23
23
|
* `loadMore` appears only on `CanLoadMore`, the one state it can make
|
|
24
24
|
* progress from. The underlying Convex hook exposes it on every status, but
|
|
25
25
|
* calling it while a page is in flight, once the list is exhausted, or after
|
|
26
|
-
* a failure is an intentional no-op there; narrowing to `CanLoadMore
|
|
27
|
-
* {@link isCanLoadMore} or {@link match}
|
|
26
|
+
* a failure is an intentional no-op there; narrowing to `CanLoadMore`—via
|
|
27
|
+
* {@link isCanLoadMore} or {@link match}—makes that statically apparent
|
|
28
28
|
* instead of silently dropping the call.
|
|
29
29
|
*
|
|
30
30
|
* When the query declares no `error` schema (`E` is `never`), the `Failure`
|
|
@@ -48,7 +48,7 @@ export type PaginatedQueryResult<Item, E = never> = [E] extends [never]
|
|
|
48
48
|
| Failure<Item, E>;
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
|
-
* Every variant regardless of `E
|
|
51
|
+
* Every variant regardless of `E`—the parameter type for guards and
|
|
52
52
|
* {@link match}, since `PaginatedQueryResult` itself excludes `Failure` when
|
|
53
53
|
* `E` is `never`.
|
|
54
54
|
*/
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EXPERIMENTAL—the pure state machine behind {@link useStreamPaginatedQuery}
|
|
3
|
+
* (see `notes/stream-based-querying.md` in the repo root).
|
|
4
|
+
*
|
|
5
|
+
* Reactive pagination without the query journal: every loaded page—including the first—is pinned to a fixed index range by echoing its
|
|
6
|
+
* `continueCursor` back as the next subscription's `endCursor` (the last
|
|
7
|
+
* page pins to the end-of-stream sentinel once exhausted), so adjacent
|
|
8
|
+
* pages stay exactly contiguous and pages never shed items as data changes—the "range-defined pages" the Fully Reactive Pagination article calls
|
|
9
|
+
* for, and the mechanism of `convex-helpers/react`'s `usePaginatedQuery`.
|
|
10
|
+
*
|
|
11
|
+
* Pinning a freshly loaded page, loading more, and splitting an overgrown
|
|
12
|
+
* page are the same transition shape: the affected page keeps rendering
|
|
13
|
+
* while its replacement subscriptions ("ongoing split") load, and is
|
|
14
|
+
* swapped out once all of them have results.
|
|
15
|
+
*
|
|
16
|
+
* This module is framework-free (it imports only `effect/*` and a type
|
|
17
|
+
* from `convex/server`); it lives here for now because the React hook is
|
|
18
|
+
* its only consumer—a second client (e.g. `@confect/foldkit`) should
|
|
19
|
+
* prompt a move down to `@confect/js`.
|
|
20
|
+
*/
|
|
21
|
+
import type { PaginationResult } from "convex/server";
|
|
22
|
+
import * as Array from "effect/Array";
|
|
23
|
+
import * as Option from "effect/Option";
|
|
24
|
+
import { pipe } from "effect/Function";
|
|
25
|
+
import * as Record from "effect/Record";
|
|
26
|
+
|
|
27
|
+
/** The `paginationOpts` one subscribed page queries with. */
|
|
28
|
+
export interface PageRequest {
|
|
29
|
+
readonly numItems: number;
|
|
30
|
+
readonly cursor: string | null;
|
|
31
|
+
/** Present once the page is pinned to a fixed range. */
|
|
32
|
+
readonly endCursor?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Per-page read budgets, forwarded to the server so a scan-heavy page
|
|
35
|
+
* fails over to `SplitRequired` instead of exceeding query limits.
|
|
36
|
+
*/
|
|
37
|
+
readonly maximumRowsRead?: number;
|
|
38
|
+
readonly maximumBytesRead?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The per-page read budgets every growing page is requested with. */
|
|
42
|
+
export interface ReadBudget {
|
|
43
|
+
readonly maximumRowsRead?: number | undefined;
|
|
44
|
+
readonly maximumBytesRead?: number | undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A fresh growing-page request (no `endCursor`). */
|
|
48
|
+
const growingRequest = (
|
|
49
|
+
numItems: number,
|
|
50
|
+
cursor: string | null,
|
|
51
|
+
budget: ReadBudget,
|
|
52
|
+
): PageRequest => ({
|
|
53
|
+
numItems,
|
|
54
|
+
cursor,
|
|
55
|
+
...(budget.maximumRowsRead === undefined
|
|
56
|
+
? {}
|
|
57
|
+
: { maximumRowsRead: budget.maximumRowsRead }),
|
|
58
|
+
...(budget.maximumBytesRead === undefined
|
|
59
|
+
? {}
|
|
60
|
+
: { maximumBytesRead: budget.maximumBytesRead }),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
export interface State {
|
|
64
|
+
readonly nextPageKey: number;
|
|
65
|
+
/** Page keys in display order. */
|
|
66
|
+
readonly pageKeys: ReadonlyArray<string>;
|
|
67
|
+
/** The request each subscribed page queries with, by page key. */
|
|
68
|
+
readonly pages: Record.ReadonlyRecord<string, PageRequest>;
|
|
69
|
+
/**
|
|
70
|
+
* Pages being replaced by one (a pin) or two (a split) narrower
|
|
71
|
+
* subscriptions: the original stays subscribed (and rendered) until every
|
|
72
|
+
* replacement has a result.
|
|
73
|
+
*/
|
|
74
|
+
readonly ongoingSplits: Record.ReadonlyRecord<string, ReadonlyArray<string>>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The skipped state: nothing subscribed. */
|
|
78
|
+
export const empty: State = {
|
|
79
|
+
nextPageKey: 0,
|
|
80
|
+
pageKeys: [],
|
|
81
|
+
pages: Record.empty(),
|
|
82
|
+
ongoingSplits: Record.empty(),
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** One growing (unpinned) page from the start of the stream. */
|
|
86
|
+
export const initial = (
|
|
87
|
+
initialNumItems: number,
|
|
88
|
+
budget: ReadBudget = {},
|
|
89
|
+
): State => ({
|
|
90
|
+
nextPageKey: 1,
|
|
91
|
+
pageKeys: ["0"],
|
|
92
|
+
pages: Record.singleton("0", growingRequest(initialNumItems, null, budget)),
|
|
93
|
+
ongoingSplits: Record.empty(),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Pin a freshly loaded growing page at its `continueCursor` (or, once
|
|
98
|
+
* exhausted, at the end-of-stream sentinel), so it stops being a sliding
|
|
99
|
+
* window: a pinned page grows and shrinks with the data in its range but
|
|
100
|
+
* never sheds items past its edges. Modeled as a one-replacement split so
|
|
101
|
+
* the original keeps rendering until the pinned twin has a result.
|
|
102
|
+
*/
|
|
103
|
+
export const pin =
|
|
104
|
+
(key: string, endCursor: string): ((state: State) => State) =>
|
|
105
|
+
(state) =>
|
|
106
|
+
Option.match(Record.get(state.pages, key), {
|
|
107
|
+
onNone: () => state,
|
|
108
|
+
onSome: (page) => {
|
|
109
|
+
if (
|
|
110
|
+
page.endCursor !== undefined ||
|
|
111
|
+
Record.has(state.ongoingSplits, key)
|
|
112
|
+
) {
|
|
113
|
+
return state;
|
|
114
|
+
}
|
|
115
|
+
const pinnedKey = String(state.nextPageKey);
|
|
116
|
+
return {
|
|
117
|
+
nextPageKey: state.nextPageKey + 1,
|
|
118
|
+
pageKeys: state.pageKeys,
|
|
119
|
+
pages: Record.set(state.pages, pinnedKey, { ...page, endCursor }),
|
|
120
|
+
ongoingSplits: Record.set(state.ongoingSplits, key, [pinnedKey]),
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Pin the growing last page at `continueCursor` and start a new growing
|
|
127
|
+
* page from there. Modeled as a split of the last page into its pinned
|
|
128
|
+
* replacement and the new page, so the swap waits for both.
|
|
129
|
+
*/
|
|
130
|
+
export const loadMore =
|
|
131
|
+
(
|
|
132
|
+
continueCursor: string,
|
|
133
|
+
numItems: number,
|
|
134
|
+
budget: ReadBudget = {},
|
|
135
|
+
): ((state: State) => State) =>
|
|
136
|
+
(state) =>
|
|
137
|
+
pipe(
|
|
138
|
+
Option.Do,
|
|
139
|
+
Option.bind("lastKey", () => Array.last(state.pageKeys)),
|
|
140
|
+
Option.bind("lastPage", ({ lastKey }) =>
|
|
141
|
+
Record.get(state.pages, lastKey),
|
|
142
|
+
),
|
|
143
|
+
Option.match({
|
|
144
|
+
onNone: () => state,
|
|
145
|
+
onSome: ({ lastKey, lastPage }) => {
|
|
146
|
+
if (lastPage.endCursor !== undefined) {
|
|
147
|
+
// The last page is already pinned (the usual case, since pages
|
|
148
|
+
// pin themselves on first load): just append a new growing page.
|
|
149
|
+
const nextKey = String(state.nextPageKey);
|
|
150
|
+
return {
|
|
151
|
+
nextPageKey: state.nextPageKey + 1,
|
|
152
|
+
pageKeys: Array.append(state.pageKeys, nextKey),
|
|
153
|
+
pages: Record.set(
|
|
154
|
+
state.pages,
|
|
155
|
+
nextKey,
|
|
156
|
+
growingRequest(numItems, continueCursor, budget),
|
|
157
|
+
),
|
|
158
|
+
ongoingSplits: state.ongoingSplits,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const pinnedKey = String(state.nextPageKey);
|
|
162
|
+
const nextKey = String(state.nextPageKey + 1);
|
|
163
|
+
return {
|
|
164
|
+
nextPageKey: state.nextPageKey + 2,
|
|
165
|
+
pageKeys: state.pageKeys,
|
|
166
|
+
pages: pipe(
|
|
167
|
+
state.pages,
|
|
168
|
+
Record.set(pinnedKey, { ...lastPage, endCursor: continueCursor }),
|
|
169
|
+
Record.set(
|
|
170
|
+
nextKey,
|
|
171
|
+
growingRequest(numItems, continueCursor, budget),
|
|
172
|
+
),
|
|
173
|
+
),
|
|
174
|
+
ongoingSplits: Record.set(state.ongoingSplits, lastKey, [
|
|
175
|
+
pinnedKey,
|
|
176
|
+
nextKey,
|
|
177
|
+
]),
|
|
178
|
+
};
|
|
179
|
+
},
|
|
180
|
+
}),
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Split the page at `key` in two at `splitCursor`. The first replacement
|
|
185
|
+
* pins at the split point; the second keeps the page's own `endCursor`—for a pinned page its original end (so a truncated `SplitRequired` page
|
|
186
|
+
* loses none of its range), and for a growing page no end at all (it keeps
|
|
187
|
+
* growing).
|
|
188
|
+
*/
|
|
189
|
+
export const split =
|
|
190
|
+
(key: string, splitCursor: string): ((state: State) => State) =>
|
|
191
|
+
(state) =>
|
|
192
|
+
Option.match(Record.get(state.pages, key), {
|
|
193
|
+
onNone: () => state,
|
|
194
|
+
onSome: (page) => {
|
|
195
|
+
const firstKey = String(state.nextPageKey);
|
|
196
|
+
const secondKey = String(state.nextPageKey + 1);
|
|
197
|
+
return {
|
|
198
|
+
nextPageKey: state.nextPageKey + 2,
|
|
199
|
+
pageKeys: state.pageKeys,
|
|
200
|
+
pages: pipe(
|
|
201
|
+
state.pages,
|
|
202
|
+
Record.set(firstKey, { ...page, endCursor: splitCursor }),
|
|
203
|
+
Record.set(secondKey, { ...page, cursor: splitCursor }),
|
|
204
|
+
),
|
|
205
|
+
ongoingSplits: Record.set(state.ongoingSplits, key, [
|
|
206
|
+
firstKey,
|
|
207
|
+
secondKey,
|
|
208
|
+
]),
|
|
209
|
+
};
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
/** Swap a completed split's replacements in for the original page. */
|
|
214
|
+
export const completeSplit =
|
|
215
|
+
(key: string): ((state: State) => State) =>
|
|
216
|
+
(state) =>
|
|
217
|
+
Option.match(Record.get(state.ongoingSplits, key), {
|
|
218
|
+
onNone: () => state,
|
|
219
|
+
onSome: (replacements) => ({
|
|
220
|
+
nextPageKey: state.nextPageKey,
|
|
221
|
+
pageKeys: Array.flatMap(state.pageKeys, (pageKey) =>
|
|
222
|
+
pageKey === key ? replacements : [pageKey],
|
|
223
|
+
),
|
|
224
|
+
pages: Record.remove(state.pages, key),
|
|
225
|
+
ongoingSplits: Record.remove(state.ongoingSplits, key),
|
|
226
|
+
}),
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
/** Whether the last (rendered) page is currently being split or pinned. */
|
|
230
|
+
export const isLastPageSplitting = (state: State): boolean =>
|
|
231
|
+
Option.exists(Array.last(state.pageKeys), (lastKey) =>
|
|
232
|
+
Record.has(state.ongoingSplits, lastKey),
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
/** Build one subscription request per subscribed page. */
|
|
236
|
+
export const pageRequests = <A>(
|
|
237
|
+
state: State,
|
|
238
|
+
f: (page: PageRequest) => A,
|
|
239
|
+
): Record.ReadonlyRecord<string, A> => Record.map(state.pages, f);
|
|
240
|
+
|
|
241
|
+
/** The per-page results a render observes, keyed like {@link pageRequests}. */
|
|
242
|
+
export type Results = Record.ReadonlyRecord<
|
|
243
|
+
string,
|
|
244
|
+
PageResult | Error | undefined
|
|
245
|
+
>;
|
|
246
|
+
|
|
247
|
+
// -----------------------------------------------------------------------------
|
|
248
|
+
// Interpretation
|
|
249
|
+
// -----------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The wire shape of one loaded page—`PaginationResult` from
|
|
253
|
+
* `convex/server`, so the protocol has a single source of truth.
|
|
254
|
+
*/
|
|
255
|
+
export type PageResult = PaginationResult<unknown>;
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* What one render pass derives from the subscribed pages' results: the
|
|
259
|
+
* items to show, the trailing complete result (absent while the tail is
|
|
260
|
+
* loading or force-splitting), state transitions to apply, and whether a
|
|
261
|
+
* failure or an invalid cursor was hit.
|
|
262
|
+
*/
|
|
263
|
+
export type Interpretation =
|
|
264
|
+
| { readonly _tag: "ResetRequired" }
|
|
265
|
+
| {
|
|
266
|
+
readonly _tag: "Failed";
|
|
267
|
+
readonly error: unknown;
|
|
268
|
+
/** Encoded items loaded before the failing page. */
|
|
269
|
+
readonly items: ReadonlyArray<unknown>;
|
|
270
|
+
}
|
|
271
|
+
| {
|
|
272
|
+
readonly _tag: "Interpreted";
|
|
273
|
+
/** Encoded items across the loaded pages, in order. */
|
|
274
|
+
readonly items: ReadonlyArray<unknown>;
|
|
275
|
+
/** The last page's result, when every page has loaded completely. */
|
|
276
|
+
readonly lastResult: Option.Option<PageResult>;
|
|
277
|
+
/** Split/swap transitions this render discovered. */
|
|
278
|
+
readonly transitions: ReadonlyArray<(state: State) => State>;
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const interpreted = (
|
|
282
|
+
items: ReadonlyArray<unknown>,
|
|
283
|
+
lastResult: Option.Option<PageResult>,
|
|
284
|
+
transitions: ReadonlyArray<(state: State) => State>,
|
|
285
|
+
): Interpretation => ({ _tag: "Interpreted", items, lastResult, transitions });
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Walk the pages in display order, concatenating their items and
|
|
289
|
+
* collecting the transitions to apply (completed split swaps, recommended
|
|
290
|
+
* splits, eager splits of pages that outgrew `initialNumItems`).
|
|
291
|
+
*
|
|
292
|
+
* `isInvalidCursorError` marks errors that call for a full pagination
|
|
293
|
+
* reset rather than a failure (the cursor no longer matches the query).
|
|
294
|
+
*/
|
|
295
|
+
export const interpret = (
|
|
296
|
+
state: State,
|
|
297
|
+
results: Record.ReadonlyRecord<string, PageResult | Error | undefined>,
|
|
298
|
+
options: {
|
|
299
|
+
readonly initialNumItems: number;
|
|
300
|
+
readonly isInvalidCursorError: (error: Error) => boolean;
|
|
301
|
+
},
|
|
302
|
+
): Interpretation => {
|
|
303
|
+
const go = (
|
|
304
|
+
remaining: ReadonlyArray<string>,
|
|
305
|
+
items: ReadonlyArray<unknown>,
|
|
306
|
+
transitions: ReadonlyArray<(s: State) => State>,
|
|
307
|
+
lastResult: Option.Option<PageResult>,
|
|
308
|
+
): Interpretation =>
|
|
309
|
+
Option.match(Array.head(remaining), {
|
|
310
|
+
onNone: () => interpreted(items, lastResult, transitions),
|
|
311
|
+
onSome: (pageKey) => {
|
|
312
|
+
const result = Record.get(results, pageKey).pipe(
|
|
313
|
+
Option.flatMap(Option.fromNullishOr),
|
|
314
|
+
Option.getOrUndefined,
|
|
315
|
+
);
|
|
316
|
+
if (result === undefined) {
|
|
317
|
+
// The trailing pages are still loading.
|
|
318
|
+
return interpreted(items, Option.none(), transitions);
|
|
319
|
+
}
|
|
320
|
+
if (result instanceof Error) {
|
|
321
|
+
return options.isInvalidCursorError(result)
|
|
322
|
+
? { _tag: "ResetRequired" }
|
|
323
|
+
: { _tag: "Failed", error: result, items };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const hasResult = (key: string) =>
|
|
327
|
+
Record.get(results, key).pipe(
|
|
328
|
+
Option.flatMap(Option.fromNullishOr),
|
|
329
|
+
Option.isSome,
|
|
330
|
+
);
|
|
331
|
+
const ongoingSplit = Record.get(state.ongoingSplits, pageKey);
|
|
332
|
+
const nextTransitions = Option.match(ongoingSplit, {
|
|
333
|
+
onSome: (replacements) =>
|
|
334
|
+
// Swap the replacements in once all of them have results.
|
|
335
|
+
Array.every(replacements, hasResult)
|
|
336
|
+
? Array.append(transitions, completeSplit(pageKey))
|
|
337
|
+
: transitions,
|
|
338
|
+
onNone: () =>
|
|
339
|
+
typeof result.splitCursor === "string" &&
|
|
340
|
+
(result.pageStatus === "SplitRecommended" ||
|
|
341
|
+
result.pageStatus === "SplitRequired" ||
|
|
342
|
+
result.page.length > options.initialNumItems)
|
|
343
|
+
? Array.append(transitions, split(pageKey, result.splitCursor))
|
|
344
|
+
: // A loaded growing page pins itself to the range it just
|
|
345
|
+
// served (`END_CURSOR` once exhausted), so it stops being a
|
|
346
|
+
// sliding window that sheds items as new documents arrive.
|
|
347
|
+
Option.exists(
|
|
348
|
+
Record.get(state.pages, pageKey),
|
|
349
|
+
(request) => request.endCursor === undefined,
|
|
350
|
+
) && result.pageStatus !== "SplitRequired"
|
|
351
|
+
? Array.append(transitions, pin(pageKey, result.continueCursor))
|
|
352
|
+
: transitions,
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// A force-split page couldn't be fetched in full: show the items
|
|
356
|
+
// before it and report the tail as still loading.
|
|
357
|
+
return result.pageStatus === "SplitRequired"
|
|
358
|
+
? interpreted(items, Option.none(), nextTransitions)
|
|
359
|
+
: go(
|
|
360
|
+
Array.drop(remaining, 1),
|
|
361
|
+
Array.appendAll(items, result.page),
|
|
362
|
+
nextTransitions,
|
|
363
|
+
Option.some(result),
|
|
364
|
+
);
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
return go(state.pageKeys, [], [], Option.none());
|
|
369
|
+
};
|