@confect/react 9.2.5 → 9.3.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@confect/react",
3
3
  "description": "Client-side bindings for React apps",
4
- "version": "9.2.5",
4
+ "version": "9.3.0",
5
5
  "author": "RJ Dellecese",
6
6
  "bugs": {
7
7
  "url": "https://github.com/rjdellecese/confect/issues"
@@ -14,10 +14,10 @@
14
14
  "@types/react-dom": "19.2.3",
15
15
  "convex": "1.42.3",
16
16
  "effect": "3.22.0",
17
- "happy-dom": "20.11.0",
17
+ "happy-dom": "20.11.1",
18
18
  "react": "19.2.7",
19
19
  "react-dom": "19.2.7",
20
- "tsdown": "0.22.12",
20
+ "tsdown": "0.22.14",
21
21
  "typescript": "6.0.3",
22
22
  "unrun": "0.3.1",
23
23
  "vite": "8.1.5",
@@ -55,10 +55,10 @@
55
55
  "main": "./dist/index.js",
56
56
  "module": "./dist/index.js",
57
57
  "peerDependencies": {
58
- "convex": "^1.32.0",
58
+ "convex": "^1.36.0",
59
59
  "effect": "^3.21.2",
60
60
  "react": "^18.0.0 || ^19.0.0",
61
- "@confect/core": "^9.2.5"
61
+ "@confect/core": "^9.3.0"
62
62
  },
63
63
  "repository": {
64
64
  "type": "git",
@@ -0,0 +1,325 @@
1
+ import { identity } from "effect/Function";
2
+ import * as Equal from "effect/Equal";
3
+ import * as Function from "effect/Function";
4
+ import * as Hash from "effect/Hash";
5
+ import * as Match from "effect/Match";
6
+ import * as Pipeable from "effect/Pipeable";
7
+ import * as Predicate from "effect/Predicate";
8
+
9
+ const TypeId = "@confect/react/PaginatedQueryResult";
10
+ type TypeId = typeof TypeId;
11
+
12
+ /**
13
+ * A `PaginatedQueryResult` represents the result of a Confect paginated query.
14
+ * The non-`Failure` variants mirror the statuses of `usePaginatedQuery` from
15
+ * `convex/react` and all carry `results` and `isLoading`, so the common UI
16
+ * shape — render the list, show a spinner while loading — needs only an
17
+ * {@link isFailure} early-out and plain field access.
18
+ *
19
+ * Every variant carries `results`, including `Failure`: when a later page
20
+ * fails, the pages already loaded are still worth rendering alongside the
21
+ * error, so they are not discarded. `Failure` additionally carries the
22
+ * query's decoded typed error.
23
+ *
24
+ * `loadMore` appears only on `CanLoadMore`, the one state it can make
25
+ * progress from. The underlying Convex hook exposes it on every status, but
26
+ * calling it while a page is in flight, once the list is exhausted, or after
27
+ * a failure is an intentional no-op there; narrowing to `CanLoadMore` — via
28
+ * {@link isCanLoadMore} or {@link match} — makes that statically apparent
29
+ * instead of silently dropping the call.
30
+ *
31
+ * When the query declares no `error` schema (`E` is `never`), the `Failure`
32
+ * variant is excluded from the type entirely, so `isLoading` is accessible
33
+ * without any narrowing.
34
+ *
35
+ * @typeParam Item - The type of a decoded page item.
36
+ * @typeParam E - The type of the decoded typed error in the `Failure` variant.
37
+ */
38
+ export type PaginatedQueryResult<Item, E = never> = [E] extends [never]
39
+ ?
40
+ | LoadingFirstPage<Item, E>
41
+ | LoadingMore<Item, E>
42
+ | CanLoadMore<Item, E>
43
+ | Exhausted<Item, E>
44
+ :
45
+ | LoadingFirstPage<Item, E>
46
+ | LoadingMore<Item, E>
47
+ | CanLoadMore<Item, E>
48
+ | Exhausted<Item, E>
49
+ | Failure<Item, E>;
50
+
51
+ /**
52
+ * Every variant regardless of `E` — the parameter type for guards and
53
+ * {@link match}, since `PaginatedQueryResult` itself excludes `Failure` when
54
+ * `E` is `never`.
55
+ */
56
+ export type Variants<Item, E = never> =
57
+ | LoadingFirstPage<Item, E>
58
+ | LoadingMore<Item, E>
59
+ | CanLoadMore<Item, E>
60
+ | Exhausted<Item, E>
61
+ | Failure<Item, E>;
62
+
63
+ export declare namespace PaginatedQueryResult {
64
+ // eslint-disable-next-line @typescript-eslint/no-shadow, import/namespace -- oxlint's namespace resolution misses type-only exports, and `Pipeable` is an interface
65
+ export interface Proto<Item, E> extends Pipeable.Pipeable {
66
+ readonly [TypeId]: {
67
+ readonly E: (_: never) => E;
68
+ readonly Item: (_: never) => Item;
69
+ };
70
+ }
71
+
72
+ export type Item<R> = R extends Proto<infer Item_, infer _E> ? Item_ : never;
73
+
74
+ // eslint-disable-next-line @typescript-eslint/no-shadow
75
+ export type Failure<R> = R extends Proto<infer _Item, infer E> ? E : never;
76
+ }
77
+
78
+ export interface LoadingFirstPage<
79
+ Item,
80
+ E = never,
81
+ > extends PaginatedQueryResult.Proto<Item, E> {
82
+ readonly _tag: "LoadingFirstPage";
83
+ readonly skipped: boolean;
84
+ readonly results: ReadonlyArray<Item>;
85
+ readonly isLoading: true;
86
+ }
87
+
88
+ export interface LoadingMore<
89
+ Item,
90
+ E = never,
91
+ > extends PaginatedQueryResult.Proto<Item, E> {
92
+ readonly _tag: "LoadingMore";
93
+ readonly results: ReadonlyArray<Item>;
94
+ readonly isLoading: true;
95
+ }
96
+
97
+ export interface CanLoadMore<
98
+ Item,
99
+ E = never,
100
+ > extends PaginatedQueryResult.Proto<Item, E> {
101
+ readonly _tag: "CanLoadMore";
102
+ readonly results: ReadonlyArray<Item>;
103
+ readonly isLoading: false;
104
+ readonly loadMore: (numItems: number) => void;
105
+ }
106
+
107
+ export interface Exhausted<Item, E = never> extends PaginatedQueryResult.Proto<
108
+ Item,
109
+ E
110
+ > {
111
+ readonly _tag: "Exhausted";
112
+ readonly results: ReadonlyArray<Item>;
113
+ readonly isLoading: false;
114
+ }
115
+
116
+ export interface Failure<Item, E = never> extends PaginatedQueryResult.Proto<
117
+ Item,
118
+ E
119
+ > {
120
+ readonly _tag: "Failure";
121
+ readonly error: E;
122
+ /** The pages loaded before the failure. */
123
+ readonly results: ReadonlyArray<Item>;
124
+ }
125
+
126
+ export const isPaginatedQueryResult = (
127
+ u: unknown,
128
+ ): u is Variants<unknown, unknown> => Predicate.hasProperty(u, TypeId);
129
+
130
+ const PaginatedQueryResultProto = {
131
+ [TypeId]: {
132
+ E: identity,
133
+ Item: identity,
134
+ },
135
+ pipe(this: Variants<any, any>, ...args: ReadonlyArray<unknown>) {
136
+ return Pipeable.pipeArguments(
137
+ this,
138
+ args as unknown as Parameters<typeof Pipeable.pipeArguments>[1],
139
+ );
140
+ },
141
+ /**
142
+ * `CanLoadMore`'s `loadMore` is excluded from equality and hashing, like
143
+ * function fields generally: two results that render identically are equal
144
+ * even though their callbacks differ by identity.
145
+ */
146
+ [Equal.symbol](this: Variants<any, any>, that: Variants<any, any>): boolean {
147
+ if (this._tag !== that._tag) {
148
+ return false;
149
+ }
150
+ return Match.value(this).pipe(
151
+ Match.tag(
152
+ "LoadingFirstPage",
153
+ (self) => self.skipped === (that as LoadingFirstPage<any, any>).skipped,
154
+ ),
155
+ Match.tag("LoadingMore", "CanLoadMore", "Exhausted", (self) =>
156
+ Equal.equals(self.results, that.results),
157
+ ),
158
+ Match.tag(
159
+ "Failure",
160
+ (self) =>
161
+ Equal.equals(self.error, (that as Failure<any, any>).error) &&
162
+ Equal.equals(self.results, (that as Failure<any, any>).results),
163
+ ),
164
+ Match.exhaustive,
165
+ );
166
+ },
167
+ [Hash.symbol](this: Variants<any, any>): number {
168
+ const tagHash = Hash.string(this._tag);
169
+ return Match.value(this).pipe(
170
+ Match.tag("LoadingFirstPage", (self) =>
171
+ Hash.cached(self, Hash.combine(tagHash)(Hash.hash(self.skipped))),
172
+ ),
173
+ Match.tag("LoadingMore", "CanLoadMore", "Exhausted", (self) =>
174
+ Hash.cached(self, Hash.combine(tagHash)(Hash.hash(self.results))),
175
+ ),
176
+ Match.tag("Failure", (self) =>
177
+ Hash.cached(
178
+ self,
179
+ Hash.combine(tagHash)(
180
+ Hash.combine(Hash.hash(self.error))(Hash.hash(self.results)),
181
+ ),
182
+ ),
183
+ ),
184
+ Match.exhaustive,
185
+ );
186
+ },
187
+ };
188
+
189
+ const noResults: ReadonlyArray<never> = [];
190
+
191
+ export const loadingFirstPage = <Item = never, E = never>(options: {
192
+ skipped: boolean;
193
+ }): LoadingFirstPage<Item, E> =>
194
+ Object.assign(Object.create(PaginatedQueryResultProto), {
195
+ _tag: "LoadingFirstPage" as const,
196
+ skipped: options.skipped,
197
+ results: noResults,
198
+ isLoading: true as const,
199
+ });
200
+
201
+ export const loadingMore = <Item, E = never>(options: {
202
+ results: ReadonlyArray<Item>;
203
+ }): LoadingMore<Item, E> =>
204
+ Object.assign(Object.create(PaginatedQueryResultProto), {
205
+ _tag: "LoadingMore" as const,
206
+ results: options.results,
207
+ isLoading: true as const,
208
+ });
209
+
210
+ export const canLoadMore = <Item, E = never>(options: {
211
+ results: ReadonlyArray<Item>;
212
+ loadMore: (numItems: number) => void;
213
+ }): CanLoadMore<Item, E> =>
214
+ Object.assign(Object.create(PaginatedQueryResultProto), {
215
+ _tag: "CanLoadMore" as const,
216
+ results: options.results,
217
+ isLoading: false as const,
218
+ loadMore: options.loadMore,
219
+ });
220
+
221
+ export const exhausted = <Item, E = never>(options: {
222
+ results: ReadonlyArray<Item>;
223
+ }): Exhausted<Item, E> =>
224
+ Object.assign(Object.create(PaginatedQueryResultProto), {
225
+ _tag: "Exhausted" as const,
226
+ results: options.results,
227
+ isLoading: false as const,
228
+ });
229
+
230
+ export const failure = <E, Item = never>(options: {
231
+ error: E;
232
+ results: ReadonlyArray<Item>;
233
+ }): Failure<Item, E> =>
234
+ Object.assign(Object.create(PaginatedQueryResultProto), {
235
+ _tag: "Failure" as const,
236
+ error: options.error,
237
+ results: options.results,
238
+ });
239
+
240
+ export const isLoadingFirstPage = <Item, E>(
241
+ result: Variants<Item, E>,
242
+ ): result is LoadingFirstPage<Item, E> => result._tag === "LoadingFirstPage";
243
+
244
+ export const isLoadingMore = <Item, E>(
245
+ result: Variants<Item, E>,
246
+ ): result is LoadingMore<Item, E> => result._tag === "LoadingMore";
247
+
248
+ export const isCanLoadMore = <Item, E>(
249
+ result: Variants<Item, E>,
250
+ ): result is CanLoadMore<Item, E> => result._tag === "CanLoadMore";
251
+
252
+ export const isExhausted = <Item, E>(
253
+ result: Variants<Item, E>,
254
+ ): result is Exhausted<Item, E> => result._tag === "Exhausted";
255
+
256
+ export const isFailure = <Item, E>(
257
+ result: Variants<Item, E>,
258
+ ): result is Failure<Item, E> => result._tag === "Failure";
259
+
260
+ export const isLoading = <Item, E>(
261
+ result: Variants<Item, E>,
262
+ ): result is LoadingFirstPage<Item, E> | LoadingMore<Item, E> =>
263
+ result._tag === "LoadingFirstPage" || result._tag === "LoadingMore";
264
+
265
+ type MatchOptions<Item, E, V, W, X, Y, Z> = {
266
+ readonly onLoadingFirstPage: (skipped: boolean) => V;
267
+ readonly onLoadingMore: (results: ReadonlyArray<Item>) => W;
268
+ readonly onCanLoadMore: (
269
+ results: ReadonlyArray<Item>,
270
+ loadMore: (numItems: number) => void,
271
+ ) => X;
272
+ readonly onExhausted: (results: ReadonlyArray<Item>) => Y;
273
+ } & ([E] extends [never]
274
+ ? {}
275
+ : {
276
+ readonly onFailure: (error: E, results: ReadonlyArray<Item>) => Z;
277
+ });
278
+
279
+ type MatchReturns<E, V, W, X, Y, Z> = [E] extends [never]
280
+ ? V | W | X | Y
281
+ : V | W | X | Y | Z;
282
+
283
+ /**
284
+ * Matches a {@link PaginatedQueryResult} to the appropriate handler based on
285
+ * its tag. If the provided result cannot fail (i.e. `E` is `never`),
286
+ * `onFailure` is not required.
287
+ */
288
+ export const match: {
289
+ <Item, E, V, W, X, Y, Z = never>(
290
+ options: MatchOptions<Item, E, V, W, X, Y, Z>,
291
+ ): (self: Variants<Item, E>) => MatchReturns<E, V, W, X, Y, Z>;
292
+ <Item, E, V, W, X, Y, Z = never>(
293
+ self: Variants<Item, E>,
294
+ options: MatchOptions<Item, E, V, W, X, Y, Z>,
295
+ ): MatchReturns<E, V, W, X, Y, Z>;
296
+ } = Function.dual(
297
+ 2,
298
+ <Item, E, V, W, X, Y, Z = never>(
299
+ self: Variants<Item, E>,
300
+ options: MatchOptions<Item, E, V, W, X, Y, Z>,
301
+ ): MatchReturns<E, V, W, X, Y, Z> =>
302
+ Match.value(self).pipe(
303
+ Match.tag("LoadingFirstPage", (loadingFirstPage_) =>
304
+ options.onLoadingFirstPage(loadingFirstPage_.skipped),
305
+ ),
306
+ Match.tag("LoadingMore", (loadingMore_) =>
307
+ options.onLoadingMore(loadingMore_.results),
308
+ ),
309
+ Match.tag("CanLoadMore", (canLoadMore_) =>
310
+ options.onCanLoadMore(canLoadMore_.results, canLoadMore_.loadMore),
311
+ ),
312
+ Match.tag("Exhausted", (exhausted_) =>
313
+ options.onExhausted(exhausted_.results),
314
+ ),
315
+ Match.tag("Failure", (failure_) => {
316
+ if (Predicate.hasProperty(options, "onFailure")) {
317
+ return options.onFailure(failure_.error, failure_.results);
318
+ }
319
+ throw new Error(
320
+ "`onFailure` is required when error schema is provided",
321
+ );
322
+ }),
323
+ Match.exhaustive,
324
+ ) as MatchReturns<E, V, W, X, Y, Z>,
325
+ );
@@ -2,6 +2,7 @@ import { identity } from "effect/Function";
2
2
  import * as Equal from "effect/Equal";
3
3
  import * as Function from "effect/Function";
4
4
  import * as Hash from "effect/Hash";
5
+ import * as Match from "effect/Match";
5
6
  import * as Pipeable from "effect/Pipeable";
6
7
  import * as Predicate from "effect/Predicate";
7
8
 
@@ -20,6 +21,7 @@ export type QueryResult<A, E = never> =
20
21
  | Failure<A, E>;
21
22
 
22
23
  export declare namespace QueryResult {
24
+ // eslint-disable-next-line import/namespace -- oxlint's namespace resolution misses type-only exports, and `Pipeable` is an interface
23
25
  export interface Proto<A, E> extends Pipeable.Pipeable {
24
26
  readonly [TypeId]: {
25
27
  readonly E: (_: never) => E;
@@ -70,28 +72,34 @@ const QueryResultProto = {
70
72
  if (this._tag !== that._tag) {
71
73
  return false;
72
74
  }
73
- switch (this._tag) {
74
- case "Loading":
75
- return this.skipped === (that as Loading<any, any>).skipped;
76
- case "Success":
77
- return Equal.equals(this.value, (that as Success<any, any>).value);
78
- case "Failure":
79
- return Equal.equals(this.error, (that as Failure<any, any>).error);
80
- }
75
+ return Match.value(this).pipe(
76
+ Match.tag(
77
+ "Loading",
78
+ (self) => self.skipped === (that as Loading<any, any>).skipped,
79
+ ),
80
+ Match.tag("Success", (self) =>
81
+ Equal.equals(self.value, (that as Success<any, any>).value),
82
+ ),
83
+ Match.tag("Failure", (self) =>
84
+ Equal.equals(self.error, (that as Failure<any, any>).error),
85
+ ),
86
+ Match.exhaustive,
87
+ );
81
88
  },
82
89
  [Hash.symbol](this: QueryResult<any, any>): number {
83
90
  const tagHash = Hash.string(this._tag);
84
- switch (this._tag) {
85
- case "Loading":
86
- return Hash.cached(
87
- this,
88
- Hash.combine(tagHash)(Hash.hash(this.skipped)),
89
- );
90
- case "Success":
91
- return Hash.cached(this, Hash.combine(tagHash)(Hash.hash(this.value)));
92
- case "Failure":
93
- return Hash.cached(this, Hash.combine(tagHash)(Hash.hash(this.error)));
94
- }
91
+ return Match.value(this).pipe(
92
+ Match.tag("Loading", (self) =>
93
+ Hash.cached(self, Hash.combine(tagHash)(Hash.hash(self.skipped))),
94
+ ),
95
+ Match.tag("Success", (self) =>
96
+ Hash.cached(self, Hash.combine(tagHash)(Hash.hash(self.value))),
97
+ ),
98
+ Match.tag("Failure", (self) =>
99
+ Hash.cached(self, Hash.combine(tagHash)(Hash.hash(self.error))),
100
+ ),
101
+ Match.exhaustive,
102
+ );
95
103
  },
96
104
  };
97
105
 
@@ -158,20 +166,18 @@ export const match: {
158
166
  <A, E, X, Y, Z = never>(
159
167
  self: QueryResult<A, E>,
160
168
  options: MatchOptions<A, E, X, Y, Z>,
161
- ): MatchReturns<E, X, Y, Z> => {
162
- switch (self._tag) {
163
- case "Loading":
164
- return options.onLoading(self.skipped);
165
- case "Success":
166
- return options.onSuccess(self.value);
167
- case "Failure": {
169
+ ): MatchReturns<E, X, Y, Z> =>
170
+ Match.value(self).pipe(
171
+ Match.tag("Loading", (loading) => options.onLoading(loading.skipped)),
172
+ Match.tag("Success", (success) => options.onSuccess(success.value)),
173
+ Match.tag("Failure", (failure) => {
168
174
  if (Predicate.hasProperty(options, "onFailure")) {
169
- return options.onFailure(self.error) as MatchReturns<E, X, Y, Z>;
175
+ return options.onFailure(failure.error);
170
176
  }
171
177
  throw new Error(
172
178
  "`onFailure` is required when error schema is provided",
173
179
  );
174
- }
175
- }
176
- },
180
+ }),
181
+ Match.exhaustive,
182
+ ) as MatchReturns<E, X, Y, Z>,
177
183
  );