@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/src/index.ts CHANGED
@@ -6,24 +6,31 @@ import type { usePaginatedQuery as useConvexPaginatedQuery } from "convex/react"
6
6
  import {
7
7
  useAction as useConvexAction,
8
8
  useMutation as useConvexMutation,
9
+ useQueries as useConvexQueries,
9
10
  useQuery as useConvexQuery,
10
11
  type ReactMutation as ConvexReactMutation,
11
12
  } from "convex/react";
12
- import type { FunctionReference } from "convex/server";
13
- import type { Value } from "convex/values";
13
+ import { getFunctionName, type FunctionReference } from "convex/server";
14
+ import { ConvexError, convexToJson, type Value } from "convex/values";
14
15
  import * as Cause from "effect/Cause";
15
16
  import * as Effect from "effect/Effect";
16
17
  import * as Result from "effect/Result";
17
18
  import * as Exit from "effect/Exit";
18
19
  import * as Match from "effect/Match";
19
20
  import * as Option from "effect/Option";
20
- import { useCallback, useMemo } from "react";
21
+ import { useCallback, useMemo, useState } from "react";
21
22
 
22
23
  import * as OptimisticLocalStore from "./OptimisticLocalStore";
23
24
  import * as PaginatedQueryResult from "./PaginatedQueryResult";
24
25
  import * as QueryResult from "./QueryResult";
26
+ import * as StreamPagination from "./StreamPagination";
25
27
 
26
- export { OptimisticLocalStore, PaginatedQueryResult, QueryResult };
28
+ export {
29
+ OptimisticLocalStore,
30
+ PaginatedQueryResult,
31
+ QueryResult,
32
+ StreamPagination,
33
+ };
27
34
 
28
35
  export type InvokeReturn<Ref_ extends Ref.Any> = [Ref.Error<Ref_>] extends [
29
36
  never,
@@ -162,8 +169,8 @@ const useConvexPaginatedQueryInternal = (
162
169
  const MINIMUM_CONVEX_VERSION = "1.36.0";
163
170
 
164
171
  /**
165
- * The non-throwing mode of `usePaginatedQueryInternal` — its fourth,
166
- * `throwOnError` parameter — arrived in convex 1.36.0. Convex 1.32 through
172
+ * The non-throwing mode of `usePaginatedQueryInternal`—its fourth,
173
+ * `throwOnError` parameter—arrived in convex 1.36.0. Convex 1.32 through
167
174
  * 1.35 export the same symbol taking only three parameters, where a fourth
168
175
  * argument is silently ignored and errors are always thrown.
169
176
  *
@@ -222,7 +229,7 @@ export const usePaginatedQuery = <Query extends Ref.AnyPublicPaginatedQuery>(
222
229
  `usePaginatedQuery requires convex >= ${MINIMUM_CONVEX_VERSION}, but found ` +
223
230
  `${convexVersion}. Earlier versions always throw paginated query errors ` +
224
231
  "instead of returning them, so a declared `error` schema cannot be " +
225
- "surfaced as a `Failure` — upgrade the `convex` package.",
232
+ "surfaced as a `Failure`—upgrade the `convex` package.",
226
233
  );
227
234
  }
228
235
 
@@ -253,7 +260,7 @@ export const usePaginatedQuery = <Query extends Ref.AnyPublicPaginatedQuery>(
253
260
  // failures included, so results are decoded unconditionally.
254
261
  //
255
262
  // Decoding allocates fresh items, so key on the referentially stable
256
- // `results` the Convex hook provides — the same identity-preservation
263
+ // `results` the Convex hook provides—the same identity-preservation
257
264
  // rationale as in `useQuery` above.
258
265
  const { results: encodedResults } = convexResult;
259
266
  const decodedResults = useMemo(
@@ -292,8 +299,7 @@ export const usePaginatedQuery = <Query extends Ref.AnyPublicPaginatedQuery>(
292
299
  Match.when("LoadingMore", () =>
293
300
  PaginatedQueryResult.loadingMore({ results: decodedResults }),
294
301
  ),
295
- // `CanLoadMore` is the only status whose `loadMore` does anything —
296
- // the Convex hook returns an intentional no-op for all the others, so
302
+ // `CanLoadMore` is the only status whose `loadMore` does anything—the Convex hook returns an intentional no-op for all the others, so
297
303
  // they carry no callback at all.
298
304
  Match.when("CanLoadMore", () =>
299
305
  PaginatedQueryResult.canLoadMore({
@@ -306,8 +312,7 @@ export const usePaginatedQuery = <Query extends Ref.AnyPublicPaginatedQuery>(
306
312
  ),
307
313
  Match.exhaustive,
308
314
  ),
309
- // The memo produces `Variants`, the conditional return type's superset —
310
- // when `E` is `never` the `Failure` arm is unreachable at runtime, which
315
+ // The memo produces `Variants`, the conditional return type's superset—when `E` is `never` the `Failure` arm is unreachable at runtime, which
311
316
  // is exactly what the conditional encodes.
312
317
  [ref, skipped, decodedResults, status, loadMore, error],
313
318
  ) as PaginatedQueryResult.PaginatedQueryResult<
@@ -316,6 +321,276 @@ export const usePaginatedQuery = <Query extends Ref.AnyPublicPaginatedQuery>(
316
321
  >;
317
322
  };
318
323
 
324
+ /**
325
+ * Whether a paginated query error means the stored cursors no longer match
326
+ * the query (a data-dependent query changed underneath us), calling for a
327
+ * full pagination reset rather than a failure.
328
+ */
329
+ const isInvalidCursorError = (error: Error): boolean =>
330
+ error.message.includes("InvalidCursor") ||
331
+ (error instanceof ConvexError &&
332
+ typeof error.data === "object" &&
333
+ error.data !== null &&
334
+ // Both Convex's built-in pagination (which also sets
335
+ // `isConvexSystemError`) and `QueryStream.paginate` signal an invalid
336
+ // cursor with this data shape—the same check `convex/react` performs.
337
+ (error.data as { paginationError?: unknown }).paginationError ===
338
+ "InvalidCursor");
339
+
340
+ const NO_ITEMS: ReadonlyArray<unknown> = [];
341
+
342
+ /**
343
+ * EXPERIMENTAL—endCursor-pinned reactive pagination (see
344
+ * `notes/stream-based-querying.md`). Use it with paginated queries whose
345
+ * handlers paginate via `QueryStream.paginate`: those don't write the query
346
+ * journal that {@link usePaginatedQuery}'s built-in reactivity relies on,
347
+ * so gap-free pages must be maintained by the client instead. (It works
348
+ * with any paginated query honoring the `endCursor` protocol field,
349
+ * including the built-in `paginate`.)
350
+ *
351
+ * Each loaded page is pinned to a fixed index range by re-subscribing it
352
+ * with its `continueCursor` echoed back as `endCursor`—pages then grow
353
+ * and shrink reactively but always meet exactly, and a page that outgrows
354
+ * `initialNumItems` is split in two. This is the mechanism of
355
+ * `convex-helpers/react`'s `usePaginatedQuery`, re-expressed over the pure
356
+ * {@link StreamPagination} state machine, with args/items/errors codec'd
357
+ * through the ref's schemas exactly like {@link usePaginatedQuery}.
358
+ */
359
+ export const useStreamPaginatedQuery = <
360
+ Query extends Ref.AnyPublicPaginatedQuery,
361
+ >(
362
+ ref: Query,
363
+ args: UsePaginatedQueryArgs<Query>,
364
+ options: {
365
+ readonly initialNumItems: number;
366
+ /**
367
+ * Per-page read budgets forwarded as `paginationOpts.maximumRowsRead`
368
+ * and `paginationOpts.maximumBytesRead`: a page that would scan more
369
+ * rows, or read more bytes, than these returns truncated with
370
+ * `SplitRequired` (and the hook splits it) instead of exceeding
371
+ * Convex's query limits on a filter-heavy stream.
372
+ */
373
+ readonly maximumRowsRead?: number;
374
+ readonly maximumBytesRead?: number;
375
+ },
376
+ ): PaginatedQueryResult.PaginatedQueryResult<
377
+ PaginatedQueryItem<Query>,
378
+ Ref.Error<Query>
379
+ > => {
380
+ // `useQueries` requires a *referentially stable* queries object while
381
+ // nothing has changed: its subscription memo keys on identity, and its
382
+ // `useSubscription` re-runs a render-phase state update whenever the
383
+ // subscription changes—an unstable input loops the render. Callers
384
+ // typically pass a fresh `args` literal each render, so everything that
385
+ // feeds the queries object is stabilized by *value* here: the function
386
+ // reference by `ref`, and the encoded args by their serialized form (as
387
+ // `convex/react`'s own paginated hook does).
388
+ const functionReference = useMemo(() => Ref.getFunctionReference(ref), [ref]);
389
+ const skipped = args === "skip";
390
+
391
+ const rawEncodedArgs =
392
+ args === "skip"
393
+ ? undefined
394
+ : (Ref.encodePaginatedQueryArgsSync(
395
+ ref,
396
+ args as unknown as Omit<Ref.Args<Query>, "paginationOpts">,
397
+ ) as Record<string, Value>);
398
+ const encodedArgsKey =
399
+ rawEncodedArgs === undefined
400
+ ? "skip"
401
+ : JSON.stringify(convexToJson(rawEncodedArgs));
402
+ const encodedArgs = useMemo(
403
+ () => rawEncodedArgs,
404
+ // The serialized form stands in for the freshly allocated object.
405
+ [ref, encodedArgsKey],
406
+ );
407
+
408
+ // A change of query, args, skippedness, or page size restarts pagination
409
+ // from scratch.
410
+ const resetKey = useMemo(
411
+ () =>
412
+ JSON.stringify([
413
+ getFunctionName(functionReference),
414
+ encodedArgsKey,
415
+ options.initialNumItems,
416
+ options.maximumRowsRead ?? null,
417
+ options.maximumBytesRead ?? null,
418
+ ]),
419
+ [
420
+ functionReference,
421
+ encodedArgsKey,
422
+ options.initialNumItems,
423
+ options.maximumRowsRead,
424
+ options.maximumBytesRead,
425
+ ],
426
+ );
427
+
428
+ const freshState = () =>
429
+ skipped
430
+ ? StreamPagination.empty
431
+ : StreamPagination.initial(options.initialNumItems, options);
432
+
433
+ const [tracked, setTracked] = useState(() => ({
434
+ resetKey,
435
+ state: freshState(),
436
+ }));
437
+ // Render-phase reset—the React-sanctioned derived-state-from-props
438
+ // pattern, also how `convex/react`'s own paginated hook resets.
439
+ let current = tracked;
440
+ if (current.resetKey !== resetKey) {
441
+ current = { resetKey, state: freshState() };
442
+ setTracked(current);
443
+ }
444
+
445
+ const applyTransitions = useCallback(
446
+ (
447
+ transitions: ReadonlyArray<
448
+ (state: StreamPagination.State) => StreamPagination.State
449
+ >,
450
+ ) =>
451
+ setTracked((previous) => ({
452
+ resetKey: previous.resetKey,
453
+ state: transitions.reduce(
454
+ (state, transition) => transition(state),
455
+ previous.state,
456
+ ),
457
+ })),
458
+ [],
459
+ );
460
+
461
+ const queries = useMemo(
462
+ () =>
463
+ StreamPagination.pageRequests(current.state, (page) => ({
464
+ query: functionReference,
465
+ args: { ...encodedArgs, paginationOpts: page },
466
+ })),
467
+ [current.state, functionReference, encodedArgs],
468
+ );
469
+
470
+ const resultsObject = useConvexQueries(
471
+ queries as unknown as Parameters<typeof useConvexQueries>[0],
472
+ );
473
+
474
+ const interpretation = useMemo(
475
+ () =>
476
+ StreamPagination.interpret(
477
+ current.state,
478
+ resultsObject as StreamPagination.Results,
479
+ { initialNumItems: options.initialNumItems, isInvalidCursorError },
480
+ ),
481
+ [current.state, resultsObject, options.initialNumItems],
482
+ );
483
+
484
+ // Apply the transitions this render discovered (completed split swaps,
485
+ // new splits of overgrown pages) or restart after an invalid cursor—render-phase state updates, as in `convex-helpers`' hook. Both settle:
486
+ // the updated state no longer produces the same discovery.
487
+ if (interpretation._tag === "ResetRequired") {
488
+ setTracked((previous) => ({
489
+ resetKey: previous.resetKey,
490
+ state: freshState(),
491
+ }));
492
+ } else if (
493
+ interpretation._tag === "Interpreted" &&
494
+ interpretation.transitions.length > 0
495
+ ) {
496
+ applyTransitions(interpretation.transitions);
497
+ }
498
+
499
+ const encodedItems =
500
+ interpretation._tag === "ResetRequired" ? NO_ITEMS : interpretation.items;
501
+ // Page results keep their identity while unchanged (only the page that
502
+ // actually updated is a fresh object), so caching decodes per encoded
503
+ // item makes a single-page update decode only that page's items rather
504
+ // than every loaded page's.
505
+ const decodeCache = useMemo(
506
+ () => new WeakMap<object, PaginatedQueryItem<Query>>(),
507
+ [ref],
508
+ );
509
+ const decodedResults = useMemo(
510
+ () =>
511
+ encodedItems.map((item): PaginatedQueryItem<Query> => {
512
+ if (typeof item !== "object" || item === null) {
513
+ return Ref.decodePaginationPageSync(ref, [
514
+ item,
515
+ ])[0] as PaginatedQueryItem<Query>;
516
+ }
517
+ const cached = decodeCache.get(item);
518
+ if (cached !== undefined) {
519
+ return cached;
520
+ }
521
+ const decoded = Ref.decodePaginationPageSync(ref, [
522
+ item,
523
+ ])[0] as PaginatedQueryItem<Query>;
524
+ decodeCache.set(item, decoded);
525
+ return decoded;
526
+ }),
527
+ [ref, encodedItems, decodeCache],
528
+ );
529
+
530
+ return useMemo((): PaginatedQueryResult.Variants<
531
+ PaginatedQueryItem<Query>,
532
+ Ref.Error<Query>
533
+ > => {
534
+ if (interpretation._tag === "ResetRequired") {
535
+ return PaginatedQueryResult.loadingFirstPage({ skipped });
536
+ }
537
+ if (interpretation._tag === "Failed") {
538
+ const error = interpretation.error;
539
+ if (Ref.isConvexError(error)) {
540
+ const decoded = Ref.decodeErrorOption(ref, error.data);
541
+ if (Option.isSome(decoded)) {
542
+ return PaginatedQueryResult.failure({
543
+ error: decoded.value,
544
+ results: decodedResults,
545
+ });
546
+ }
547
+ }
548
+ // Unknown errors still throw, to be caught by an error boundary.
549
+ throw error;
550
+ }
551
+
552
+ const { lastResult } = interpretation;
553
+ if (Option.isNone(lastResult) && current.state.pageKeys.length <= 1) {
554
+ return PaginatedQueryResult.loadingFirstPage({ skipped });
555
+ }
556
+ if (
557
+ Option.isNone(lastResult) ||
558
+ StreamPagination.isLastPageSplitting(current.state)
559
+ ) {
560
+ return PaginatedQueryResult.loadingMore({ results: decodedResults });
561
+ }
562
+ if (lastResult.value.isDone) {
563
+ return PaginatedQueryResult.exhausted({ results: decodedResults });
564
+ }
565
+
566
+ const continueCursor = lastResult.value.continueCursor;
567
+ // A per-result guard, as in `convex-helpers`: calling `loadMore`
568
+ // repeatedly before the next render is a single load.
569
+ let alreadyLoadingMore = false;
570
+ return PaginatedQueryResult.canLoadMore({
571
+ results: decodedResults,
572
+ loadMore: (numItems: number) => {
573
+ if (!alreadyLoadingMore) {
574
+ alreadyLoadingMore = true;
575
+ applyTransitions([
576
+ StreamPagination.loadMore(continueCursor, numItems, options),
577
+ ]);
578
+ }
579
+ },
580
+ });
581
+ }, [
582
+ interpretation,
583
+ decodedResults,
584
+ current.state,
585
+ skipped,
586
+ ref,
587
+ applyTransitions,
588
+ ]) as PaginatedQueryResult.PaginatedQueryResult<
589
+ PaginatedQueryItem<Query>,
590
+ Ref.Error<Query>
591
+ >;
592
+ };
593
+
319
594
  /**
320
595
  * An optimistic update for a Confect mutation. Mirrors Convex's
321
596
  * `OptimisticUpdate`, but receives a Confect {@link OptimisticLocalStore} and