Skip to content

Commit d0369d2

Browse files
thomasballingerConvex, Inc.
authored andcommitted
Move pagination logic from React hook to a wrapper client (#41346)
Copy the reactive forever-expanding reactive paginated query logic from the `usePaginatedQuery` hook into a sidecar/wrapper client, which will eventually enable non-React clients and TanStack Query to use this logic. A new hook `usePaginatedQuery_future` is now available that is implemented with this new pagination and used in the dashboard to try it out. Today it should be identical, and if that's borne out in the dashboard we can migrate, but we'd also like to modify the behavior in the future by adding options or even changing defaults to improve caching behavior. GitOrigin-RevId: 64c4cdcdf9b1c8ae21f7a28903140c89db0c0fb5
1 parent 83f2b70 commit d0369d2

22 files changed

+2764
-1029
lines changed

npm-packages/convex/src/browser/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,16 @@ export type {
2424
ConnectionState,
2525
AuthTokenFetcher,
2626
} from "./sync/client.js";
27+
export type { PaginationStatus } from "./sync/pagination.js";
2728
export type { ConvexClientOptions } from "./simple_client.js";
2829
export { ConvexClient } from "./simple_client.js";
2930
export type {
3031
OptimisticUpdate,
3132
OptimisticLocalStore,
3233
} from "./sync/optimistic_updates.js";
3334
export type { QueryToken } from "./sync/udf_path_utils.js";
35+
/** @internal */
36+
export type { PaginatedQueryToken } from "./sync/udf_path_utils.js";
3437
export { ConvexHttpClient } from "./http_client.js";
3538
export type { HttpMutationOptions } from "./http_client.js";
3639
export type { QueryJournal } from "./sync/protocol.js";

npm-packages/convex/src/browser/simple_client.ts

Lines changed: 164 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,25 @@ import {
33
BaseConvexClient,
44
BaseConvexClientOptions,
55
MutationOptions,
6+
PaginatedQueryToken,
67
QueryToken,
78
UserIdentityAttributes,
89
} from "./index.js";
910
import {
1011
FunctionArgs,
1112
FunctionReference,
1213
FunctionReturnType,
14+
PaginationResult,
1315
} from "../server/index.js";
1416
import { getFunctionName } from "../server/api.js";
1517
import { AuthTokenFetcher } from "./sync/authentication_manager.js";
1618
import { ConnectionState } from "./sync/client.js";
19+
import {
20+
ExtendedTransition,
21+
PaginatedQueryClient,
22+
} from "./sync/paginated_query_client.js";
23+
import { PaginatedQueryResult } from "./sync/pagination.js";
24+
import { serializedQueryTokenIsPaginated } from "./sync/udf_path_utils.js";
1725

1826
// In Node.js builds this points to a bundled WebSocket implementation. If no
1927
// WebSocket implementation is manually specified or globally available,
@@ -75,6 +83,7 @@ export type Unsubscribe<T> = {
7583
export class ConvexClient {
7684
private listeners: Set<QueryInfo>;
7785
private _client: BaseConvexClient | undefined;
86+
private _paginatedClient: PaginatedQueryClient | undefined;
7887
// A synthetic server event to run callbacks the first time
7988
private callNewListenersWithCurrentValuesTimer:
8089
| ReturnType<typeof setTimeout>
@@ -91,6 +100,13 @@ export class ConvexClient {
91100
if (this._client) return this._client;
92101
throw new Error("ConvexClient is disabled");
93102
}
103+
/**
104+
* @internal
105+
*/
106+
get paginatedClient(): PaginatedQueryClient {
107+
if (this._paginatedClient) return this._paginatedClient;
108+
throw new Error("ConvexClient is disabled");
109+
}
94110
get disabled(): boolean {
95111
return this._disabled;
96112
}
@@ -123,9 +139,13 @@ export class ConvexClient {
123139
if (!this.disabled) {
124140
this._client = new BaseConvexClient(
125141
address,
126-
(updatedQueries) => this._transition(updatedQueries),
142+
() => {}, // NOP, let the paginated query client do it all
127143
baseOptions,
128144
);
145+
this._paginatedClient = new PaginatedQueryClient(
146+
this._client,
147+
(transition) => this._transition(transition),
148+
);
129149
}
130150
this.listeners = new Set();
131151
}
@@ -169,18 +189,7 @@ export class ConvexClient {
169189
onError?: (e: Error) => unknown,
170190
): Unsubscribe<Query["_returnType"]> {
171191
if (this.disabled) {
172-
const disabledUnsubscribe = (() => {}) as Unsubscribe<
173-
Query["_returnType"]
174-
>;
175-
const unsubscribeProps: RemoveCallSignature<
176-
Unsubscribe<Query["_returnType"]>
177-
> = {
178-
unsubscribe: disabledUnsubscribe,
179-
getCurrentValue: () => undefined,
180-
getQueryLogs: () => undefined,
181-
};
182-
Object.assign(disabledUnsubscribe, unsubscribeProps);
183-
return disabledUnsubscribe;
192+
return this.createDisabledUnsubscribe<Query["_returnType"]>();
184193
}
185194

186195
// BaseConvexClient takes care of deduplicating queries subscriptions...
@@ -198,6 +207,7 @@ export class ConvexClient {
198207
hasEverRun: false,
199208
query,
200209
args,
210+
paginationOptions: undefined,
201211
};
202212
this.listeners.add(queryInfo);
203213

@@ -235,22 +245,132 @@ export class ConvexClient {
235245
return ret;
236246
}
237247

248+
/**
249+
* Call a callback whenever a new result for a paginated query is received.
250+
*
251+
* This is an experimental preview: the final API may change.
252+
* In particular, caching behavior, page splitting, and required paginated query options
253+
* may change.
254+
*
255+
* @param query - A {@link server.FunctionReference} for the public query to run.
256+
* @param args - The arguments to run the query with.
257+
* @param options - Options for the paginated query including initialNumItems and id.
258+
* @param callback - Function to call when the query result updates.
259+
* @param onError - Function to call when the query result updates with an error.
260+
*
261+
* @return an {@link Unsubscribe} function to stop calling the callback.
262+
*/
263+
onPaginatedUpdate_experimental<Query extends FunctionReference<"query">>(
264+
query: Query,
265+
args: FunctionArgs<Query>,
266+
options: { initialNumItems: number },
267+
callback: (result: PaginationResult<FunctionReturnType<Query>>) => unknown,
268+
onError?: (e: Error) => unknown,
269+
): Unsubscribe<PaginatedQueryResult<FunctionReturnType<Query>[]>> {
270+
if (this.disabled) {
271+
return this.createDisabledUnsubscribe<
272+
PaginatedQueryResult<FunctionReturnType<Query>>
273+
>();
274+
}
275+
276+
const paginationOptions = {
277+
initialNumItems: options.initialNumItems,
278+
id: -1,
279+
};
280+
281+
const { paginatedQueryToken, unsubscribe } = this.paginatedClient.subscribe(
282+
getFunctionName(query),
283+
args,
284+
// Simple client doesn't use IDs, there's no expectation that these queries remain separate.
285+
paginationOptions,
286+
);
287+
288+
const queryInfo: QueryInfo = {
289+
queryToken: paginatedQueryToken,
290+
callback,
291+
onError,
292+
unsubscribe,
293+
hasEverRun: false,
294+
query,
295+
args,
296+
paginationOptions,
297+
};
298+
this.listeners.add(queryInfo);
299+
300+
// If the callback is registered for a query with a result immediately available
301+
// schedule a fake transition to call the callback soon instead of waiting for
302+
// a new server update (which could take seconds or days).
303+
if (
304+
!!this.paginatedClient.localQueryResultByToken(paginatedQueryToken) &&
305+
this.callNewListenersWithCurrentValuesTimer === undefined
306+
) {
307+
this.callNewListenersWithCurrentValuesTimer = setTimeout(
308+
() => this.callNewListenersWithCurrentValues(),
309+
0,
310+
);
311+
}
312+
313+
const unsubscribeProps: RemoveCallSignature<
314+
Unsubscribe<PaginatedQueryResult<FunctionReturnType<Query>[]>>
315+
> = {
316+
unsubscribe: () => {
317+
if (this.closed) {
318+
// all unsubscribes already ran
319+
return;
320+
}
321+
this.listeners.delete(queryInfo);
322+
unsubscribe();
323+
},
324+
getCurrentValue: () => {
325+
const result = this.paginatedClient.localQueryResult(
326+
getFunctionName(query),
327+
args,
328+
paginationOptions,
329+
);
330+
// cast to apply the specific function type
331+
return result as
332+
| PaginatedQueryResult<FunctionReturnType<Query>>
333+
| undefined;
334+
},
335+
getQueryLogs: () => [], // Paginated queries don't aggregate their logs
336+
};
337+
const ret = unsubscribeProps.unsubscribe as Unsubscribe<
338+
PaginatedQueryResult<FunctionReturnType<Query>>
339+
>;
340+
Object.assign(ret, unsubscribeProps);
341+
return ret;
342+
}
343+
238344
// Run all callbacks that have never been run before if they have a query
239345
// result available now.
240346
private callNewListenersWithCurrentValues() {
241347
this.callNewListenersWithCurrentValuesTimer = undefined;
242-
this._transition([], true);
348+
this._transition({ queries: [], paginatedQueries: [] }, true);
243349
}
244350

245351
private queryResultReady(queryToken: QueryToken): boolean {
246352
return this.client.hasLocalQueryResultByToken(queryToken);
247353
}
248354

355+
private createDisabledUnsubscribe<T>(): Unsubscribe<T> {
356+
const disabledUnsubscribe = (() => {}) as Unsubscribe<T>;
357+
const unsubscribeProps: RemoveCallSignature<Unsubscribe<T>> = {
358+
unsubscribe: disabledUnsubscribe,
359+
getCurrentValue: () => undefined,
360+
getQueryLogs: () => undefined,
361+
};
362+
Object.assign(disabledUnsubscribe, unsubscribeProps);
363+
return disabledUnsubscribe;
364+
}
365+
249366
async close() {
250367
if (this.disabled) return;
251368
// prevent pending updates
252369
this.listeners.clear();
253370
this._closed = true;
371+
if (this._paginatedClient) {
372+
this._paginatedClient = undefined;
373+
}
254374
return this.client.close();
255375
}
256376

@@ -298,22 +418,43 @@ export class ConvexClient {
298418
/**
299419
* @internal
300420
*/
301-
_transition(updatedQueries: QueryToken[], callNewListeners = false) {
421+
_transition(
422+
{
423+
queries,
424+
paginatedQueries,
425+
}: Pick<ExtendedTransition, "queries" | "paginatedQueries">,
426+
callNewListeners = false,
427+
) {
428+
const updatedQueries = [
429+
...queries.map((q) => q.token),
430+
...paginatedQueries.map((q) => q.token),
431+
];
432+
302433
// Deduping subscriptions happens in the BaseConvexClient, so not much to do here.
303434

304435
// Call all callbacks in the order they were registered
305436
for (const queryInfo of this.listeners) {
306437
const { callback, queryToken, onError, hasEverRun } = queryInfo;
438+
const isPaginatedQuery = serializedQueryTokenIsPaginated(queryToken);
439+
440+
// What does it mean to have a paginated query result ready? I think it's
441+
// always going to fire immediately.
442+
const hasResultReady = isPaginatedQuery
443+
? !!this.paginatedClient.localQueryResultByToken(queryToken)
444+
: this.client.hasLocalQueryResultByToken(queryToken);
445+
307446
if (
308447
updatedQueries.includes(queryToken) ||
309-
(callNewListeners &&
310-
!hasEverRun &&
311-
this.client.hasLocalQueryResultByToken(queryToken))
448+
(callNewListeners && !hasEverRun && hasResultReady)
312449
) {
313450
queryInfo.hasEverRun = true;
314451
let newValue;
315452
try {
316-
newValue = this.client.localQueryResultByToken(queryToken);
453+
if (isPaginatedQuery) {
454+
newValue = this.paginatedClient.localQueryResultByToken(queryToken);
455+
} else {
456+
newValue = this.client.localQueryResultByToken(queryToken);
457+
}
317458
} catch (error) {
318459
if (!(error instanceof Error)) throw error;
319460
if (onError) {
@@ -437,11 +578,13 @@ type QueryInfo = {
437578
callback: (result: any, meta: unknown) => unknown;
438579
onError: ((e: Error, meta: unknown) => unknown) | undefined;
439580
unsubscribe: () => void;
440-
queryToken: QueryToken;
581+
queryToken: QueryToken | PaginatedQueryToken;
441582
hasEverRun: boolean;
442-
// query and args are just here for debugging, the queryToken is authoritative
583+
// query, args and paginationOptions are just here for debugging, the queryToken is authoritative
443584
query: FunctionReference<"query">;
444585
args: any;
586+
paginationOptions: { initialNumItems: number; id: number } | undefined;
445587
};
446588

589+
// helps to construct objects with a call signature
447590
type RemoveCallSignature<T> = Omit<T, never>;

npm-packages/convex/src/browser/sync/client.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
/**
2+
* BaseConvexClient should not be used directly and does not provide a stable
3+
* interface. It is a "Base" client not because it expects to be inherited from
4+
* but because other clients are built around it.
5+
*
6+
* BaseConvexClient is not Convex Function type-aware: it deals
7+
* with queries as functions that return Value, not the specific value.
8+
* Use a higher-level library to get types.
9+
*/
110
import { version } from "../../index.js";
211
import { convexToJson, Value } from "../../values/index.js";
312
import {
@@ -579,8 +588,8 @@ export class BaseConvexClient {
579588
return {
580589
token,
581590
modification: {
582-
kind: "Updated",
583-
result: optimisticResult!.result,
591+
kind: "Updated" as const,
592+
result: optimisticResult,
584593
},
585594
};
586595
}),
@@ -734,7 +743,7 @@ export class BaseConvexClient {
734743
}
735744

736745
/**
737-
* Whether local query result is available for a toke.
746+
* Whether local query result is available for a token.
738747
*
739748
* This method does not throw if the result is an error.
740749
*

0 commit comments

Comments
 (0)