@sylphx/lens-react 2.0.2 → 2.1.1
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/index.d.ts +49 -42
- package/dist/index.js +29 -17
- package/package.json +2 -2
- package/src/hooks.test.tsx +425 -314
- package/src/hooks.ts +119 -88
- package/src/index.ts +4 -4
package/src/hooks.ts
CHANGED
|
@@ -6,27 +6,29 @@
|
|
|
6
6
|
*
|
|
7
7
|
* @example
|
|
8
8
|
* ```tsx
|
|
9
|
-
* import {
|
|
9
|
+
* import { useQuery, useMutation } from '@sylphx/lens-react';
|
|
10
10
|
*
|
|
11
11
|
* function UserProfile({ userId }: { userId: string }) {
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* // Client is automatically injected from context
|
|
13
|
+
* const { data: user, loading } = useQuery(
|
|
14
|
+
* (client) => client.user.get,
|
|
15
|
+
* { id: userId }
|
|
16
|
+
* );
|
|
15
17
|
* if (loading) return <Spinner />;
|
|
16
18
|
* return <h1>{user?.name}</h1>;
|
|
17
19
|
* }
|
|
18
20
|
*
|
|
19
21
|
* function CreatePost() {
|
|
20
|
-
* const
|
|
21
|
-
* const { mutate, loading } = useMutation(client.post.create);
|
|
22
|
+
* const { mutate, loading } = useMutation((client) => client.post.create);
|
|
22
23
|
* const handleCreate = () => mutate({ title: 'Hello' });
|
|
23
24
|
* return <button onClick={handleCreate} disabled={loading}>Create</button>;
|
|
24
25
|
* }
|
|
25
26
|
* ```
|
|
26
27
|
*/
|
|
27
28
|
|
|
28
|
-
import type { MutationResult, QueryResult } from "@sylphx/lens-client";
|
|
29
|
+
import type { LensClient, MutationResult, QueryResult } from "@sylphx/lens-client";
|
|
29
30
|
import { type DependencyList, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
31
|
+
import { useLensClient } from "./context.js";
|
|
30
32
|
|
|
31
33
|
// =============================================================================
|
|
32
34
|
// Types
|
|
@@ -66,40 +68,50 @@ export interface UseQueryOptions<TData = unknown, TSelected = TData> {
|
|
|
66
68
|
select?: (data: TData) => TSelected;
|
|
67
69
|
}
|
|
68
70
|
|
|
69
|
-
/**
|
|
70
|
-
|
|
71
|
+
/** Client type for callbacks */
|
|
72
|
+
type Client = LensClient<any, any>;
|
|
73
|
+
|
|
74
|
+
/** Route selector - callback that returns a route function */
|
|
75
|
+
export type RouteSelector<TParams, TResult> = (
|
|
76
|
+
client: Client,
|
|
77
|
+
) => ((params: TParams) => QueryResult<TResult>) | null;
|
|
71
78
|
|
|
72
|
-
/**
|
|
73
|
-
export type
|
|
79
|
+
/** Query accessor selector - callback that returns QueryResult */
|
|
80
|
+
export type QuerySelector<TResult> = (client: Client) => QueryResult<TResult> | null | undefined;
|
|
74
81
|
|
|
75
|
-
/** Mutation function
|
|
76
|
-
export type
|
|
82
|
+
/** Mutation selector - callback that returns mutation function */
|
|
83
|
+
export type MutationSelector<TInput, TOutput> = (
|
|
84
|
+
client: Client,
|
|
85
|
+
) => (input: TInput) => Promise<MutationResult<TOutput>>;
|
|
77
86
|
|
|
78
87
|
// =============================================================================
|
|
79
|
-
// useQuery Hook
|
|
88
|
+
// useQuery Hook
|
|
80
89
|
// =============================================================================
|
|
81
90
|
|
|
82
91
|
/**
|
|
83
92
|
* Subscribe to a query with reactive updates.
|
|
93
|
+
* Client is automatically injected from LensProvider context.
|
|
84
94
|
*
|
|
85
95
|
* Two usage patterns:
|
|
86
96
|
*
|
|
87
97
|
* **1. Route + Params (recommended)** - Stable references, no infinite loops
|
|
88
98
|
* ```tsx
|
|
89
|
-
* const { data } = useQuery(client.user.get, { id: userId });
|
|
99
|
+
* const { data } = useQuery((client) => client.user.get, { id: userId });
|
|
90
100
|
* ```
|
|
91
101
|
*
|
|
92
102
|
* **2. Accessor + Deps (escape hatch)** - For complex/composed queries
|
|
93
103
|
* ```tsx
|
|
94
|
-
* const { data } = useQuery(() => client.user.get({ id })
|
|
104
|
+
* const { data } = useQuery((client) => client.user.get({ id }), [id]);
|
|
95
105
|
* ```
|
|
96
106
|
*
|
|
97
107
|
* @example
|
|
98
108
|
* ```tsx
|
|
99
109
|
* // Basic usage - Route + Params
|
|
100
110
|
* function UserProfile({ userId }: { userId: string }) {
|
|
101
|
-
* const
|
|
102
|
-
*
|
|
111
|
+
* const { data: user, loading, error } = useQuery(
|
|
112
|
+
* (client) => client.user.get,
|
|
113
|
+
* { id: userId }
|
|
114
|
+
* );
|
|
103
115
|
*
|
|
104
116
|
* if (loading) return <Spinner />;
|
|
105
117
|
* if (error) return <Error message={error.message} />;
|
|
@@ -108,34 +120,36 @@ export type MutationFn<TInput, TOutput> = (input: TInput) => Promise<MutationRes
|
|
|
108
120
|
*
|
|
109
121
|
* // With select transform
|
|
110
122
|
* function UserName({ userId }: { userId: string }) {
|
|
111
|
-
* const
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
123
|
+
* const { data: name } = useQuery(
|
|
124
|
+
* (client) => client.user.get,
|
|
125
|
+
* { id: userId },
|
|
126
|
+
* { select: (user) => user.name }
|
|
127
|
+
* );
|
|
115
128
|
* return <span>{name}</span>;
|
|
116
129
|
* }
|
|
117
130
|
*
|
|
118
|
-
* // Conditional query
|
|
131
|
+
* // Conditional query (return null to skip)
|
|
119
132
|
* function SessionInfo({ sessionId }: { sessionId: string | null }) {
|
|
120
|
-
* const client = useLensClient();
|
|
121
133
|
* const { data } = useQuery(
|
|
122
|
-
* sessionId ? client.session.get : null,
|
|
134
|
+
* (client) => sessionId ? client.session.get : null,
|
|
123
135
|
* { id: sessionId ?? '' }
|
|
124
136
|
* );
|
|
125
137
|
* return <span>{data?.totalTokens}</span>;
|
|
126
138
|
* }
|
|
127
139
|
*
|
|
128
|
-
* // Skip query
|
|
140
|
+
* // Skip query with option
|
|
129
141
|
* function ConditionalQuery({ userId, shouldFetch }: { userId: string; shouldFetch: boolean }) {
|
|
130
|
-
* const
|
|
131
|
-
*
|
|
142
|
+
* const { data } = useQuery(
|
|
143
|
+
* (client) => client.user.get,
|
|
144
|
+
* { id: userId },
|
|
145
|
+
* { skip: !shouldFetch }
|
|
146
|
+
* );
|
|
132
147
|
* }
|
|
133
148
|
*
|
|
134
149
|
* // Complex queries with accessor (escape hatch)
|
|
135
150
|
* function ComplexQuery({ userId }: { userId: string }) {
|
|
136
|
-
* const client = useLensClient();
|
|
137
151
|
* const { data } = useQuery(
|
|
138
|
-
* () => client.user.get({ id: userId }),
|
|
152
|
+
* (client) => client.user.get({ id: userId }),
|
|
139
153
|
* [userId]
|
|
140
154
|
* );
|
|
141
155
|
* }
|
|
@@ -144,24 +158,26 @@ export type MutationFn<TInput, TOutput> = (input: TInput) => Promise<MutationRes
|
|
|
144
158
|
|
|
145
159
|
// Overload 1: Route + Params (recommended)
|
|
146
160
|
export function useQuery<TParams, TResult, TSelected = TResult>(
|
|
147
|
-
|
|
161
|
+
selector: RouteSelector<TParams, TResult>,
|
|
148
162
|
params: TParams,
|
|
149
163
|
options?: UseQueryOptions<TResult, TSelected>,
|
|
150
164
|
): UseQueryResult<TSelected>;
|
|
151
165
|
|
|
152
166
|
// Overload 2: Accessor + Deps (escape hatch for complex queries)
|
|
153
167
|
export function useQuery<TResult, TSelected = TResult>(
|
|
154
|
-
|
|
168
|
+
selector: QuerySelector<TResult>,
|
|
155
169
|
deps: DependencyList,
|
|
156
170
|
options?: UseQueryOptions<TResult, TSelected>,
|
|
157
171
|
): UseQueryResult<TSelected>;
|
|
158
172
|
|
|
159
173
|
// Implementation
|
|
160
174
|
export function useQuery<TParams, TResult, TSelected = TResult>(
|
|
161
|
-
|
|
175
|
+
selector: RouteSelector<TParams, TResult> | QuerySelector<TResult>,
|
|
162
176
|
paramsOrDeps: TParams | DependencyList,
|
|
163
177
|
options?: UseQueryOptions<TResult, TSelected>,
|
|
164
178
|
): UseQueryResult<TSelected> {
|
|
179
|
+
const client = useLensClient();
|
|
180
|
+
|
|
165
181
|
// Detect which overload is being used
|
|
166
182
|
const isAccessorMode = Array.isArray(paramsOrDeps);
|
|
167
183
|
|
|
@@ -174,21 +190,22 @@ export function useQuery<TParams, TResult, TSelected = TResult>(
|
|
|
174
190
|
if (options?.skip) return null;
|
|
175
191
|
|
|
176
192
|
if (isAccessorMode) {
|
|
177
|
-
// Accessor mode:
|
|
178
|
-
const
|
|
179
|
-
return
|
|
193
|
+
// Accessor mode: selector returns QueryResult directly
|
|
194
|
+
const querySelector = selector as QuerySelector<TResult>;
|
|
195
|
+
return querySelector(client);
|
|
180
196
|
}
|
|
181
|
-
// Route + Params mode
|
|
182
|
-
|
|
183
|
-
const route =
|
|
197
|
+
// Route + Params mode: selector returns route function
|
|
198
|
+
const routeSelector = selector as RouteSelector<TParams, TResult>;
|
|
199
|
+
const route = routeSelector(client);
|
|
200
|
+
if (!route) return null;
|
|
184
201
|
return route(paramsOrDeps as TParams);
|
|
185
202
|
},
|
|
186
203
|
// biome-ignore lint/correctness/useExhaustiveDependencies: Dynamic deps based on overload mode - intentional
|
|
187
204
|
isAccessorMode
|
|
188
205
|
? // eslint-disable-next-line react-hooks/exhaustive-deps
|
|
189
|
-
[options?.skip, ...(paramsOrDeps as DependencyList)]
|
|
206
|
+
[client, options?.skip, ...(paramsOrDeps as DependencyList)]
|
|
190
207
|
: // eslint-disable-next-line react-hooks/exhaustive-deps
|
|
191
|
-
[
|
|
208
|
+
[client, selector, paramsKey, options?.skip],
|
|
192
209
|
);
|
|
193
210
|
|
|
194
211
|
// Use ref for select to avoid it being a dependency
|
|
@@ -226,19 +243,26 @@ export function useQuery<TParams, TResult, TSelected = TResult>(
|
|
|
226
243
|
setLoading(true);
|
|
227
244
|
setError(null);
|
|
228
245
|
|
|
229
|
-
//
|
|
246
|
+
// Track if subscribe has provided data (to avoid duplicate updates from then)
|
|
247
|
+
let hasReceivedData = false;
|
|
248
|
+
|
|
249
|
+
// Subscribe to updates - primary data source for streaming
|
|
230
250
|
const unsubscribe = query.subscribe((value) => {
|
|
231
251
|
if (mountedRef.current) {
|
|
252
|
+
hasReceivedData = true;
|
|
232
253
|
setData(transform(value));
|
|
233
254
|
setLoading(false);
|
|
234
255
|
}
|
|
235
256
|
});
|
|
236
257
|
|
|
237
|
-
// Handle
|
|
258
|
+
// Handle completion/error via promise
|
|
259
|
+
// Only setData if subscribe hasn't already provided data (one-shot queries)
|
|
238
260
|
query.then(
|
|
239
261
|
(value) => {
|
|
240
|
-
if (mountedRef.current) {
|
|
262
|
+
if (mountedRef.current && !hasReceivedData) {
|
|
241
263
|
setData(transform(value));
|
|
264
|
+
}
|
|
265
|
+
if (mountedRef.current) {
|
|
242
266
|
setLoading(false);
|
|
243
267
|
}
|
|
244
268
|
},
|
|
@@ -288,15 +312,17 @@ export function useQuery<TParams, TResult, TSelected = TResult>(
|
|
|
288
312
|
// =============================================================================
|
|
289
313
|
|
|
290
314
|
/**
|
|
291
|
-
* Execute mutations with loading/error state
|
|
315
|
+
* Execute mutations with loading/error state.
|
|
316
|
+
* Client is automatically injected from LensProvider context.
|
|
292
317
|
*
|
|
293
|
-
* @param
|
|
318
|
+
* @param selector - Callback that returns mutation function from client
|
|
294
319
|
*
|
|
295
320
|
* @example
|
|
296
321
|
* ```tsx
|
|
297
322
|
* function CreatePost() {
|
|
298
|
-
* const
|
|
299
|
-
*
|
|
323
|
+
* const { mutate, loading, error, data } = useMutation(
|
|
324
|
+
* (client) => client.post.create
|
|
325
|
+
* );
|
|
300
326
|
*
|
|
301
327
|
* const handleSubmit = async (formData: FormData) => {
|
|
302
328
|
* try {
|
|
@@ -322,8 +348,7 @@ export function useQuery<TParams, TResult, TSelected = TResult>(
|
|
|
322
348
|
*
|
|
323
349
|
* // With optimistic updates
|
|
324
350
|
* function UpdatePost({ postId }: { postId: string }) {
|
|
325
|
-
* const
|
|
326
|
-
* const { mutate } = useMutation(client.post.update);
|
|
351
|
+
* const { mutate } = useMutation((client) => client.post.update);
|
|
327
352
|
*
|
|
328
353
|
* const handleUpdate = async (title: string) => {
|
|
329
354
|
* const result = await mutate({ id: postId, title });
|
|
@@ -333,8 +358,11 @@ export function useQuery<TParams, TResult, TSelected = TResult>(
|
|
|
333
358
|
* ```
|
|
334
359
|
*/
|
|
335
360
|
export function useMutation<TInput, TOutput>(
|
|
336
|
-
|
|
361
|
+
selector: MutationSelector<TInput, TOutput>,
|
|
337
362
|
): UseMutationResult<TInput, TOutput> {
|
|
363
|
+
const client = useLensClient();
|
|
364
|
+
const mutationFn = selector(client);
|
|
365
|
+
|
|
338
366
|
const [loading, setLoading] = useState(false);
|
|
339
367
|
const [error, setError] = useState<Error | null>(null);
|
|
340
368
|
const [data, setData] = useState<TOutput | null>(null);
|
|
@@ -342,6 +370,10 @@ export function useMutation<TInput, TOutput>(
|
|
|
342
370
|
// Track mounted state
|
|
343
371
|
const mountedRef = useRef(true);
|
|
344
372
|
|
|
373
|
+
// Store mutation ref for latest version
|
|
374
|
+
const mutationRef = useRef(mutationFn);
|
|
375
|
+
mutationRef.current = mutationFn;
|
|
376
|
+
|
|
345
377
|
useEffect(() => {
|
|
346
378
|
mountedRef.current = true;
|
|
347
379
|
return () => {
|
|
@@ -350,33 +382,30 @@ export function useMutation<TInput, TOutput>(
|
|
|
350
382
|
}, []);
|
|
351
383
|
|
|
352
384
|
// Mutation wrapper
|
|
353
|
-
const mutate = useCallback(
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
setError(null);
|
|
385
|
+
const mutate = useCallback(async (input: TInput): Promise<MutationResult<TOutput>> => {
|
|
386
|
+
setLoading(true);
|
|
387
|
+
setError(null);
|
|
357
388
|
|
|
358
|
-
|
|
359
|
-
|
|
389
|
+
try {
|
|
390
|
+
const result = await mutationRef.current(input);
|
|
360
391
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
392
|
+
if (mountedRef.current) {
|
|
393
|
+
setData(result.data);
|
|
394
|
+
}
|
|
364
395
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
}
|
|
371
|
-
throw mutationError;
|
|
372
|
-
} finally {
|
|
373
|
-
if (mountedRef.current) {
|
|
374
|
-
setLoading(false);
|
|
375
|
-
}
|
|
396
|
+
return result;
|
|
397
|
+
} catch (err) {
|
|
398
|
+
const mutationError = err instanceof Error ? err : new Error(String(err));
|
|
399
|
+
if (mountedRef.current) {
|
|
400
|
+
setError(mutationError);
|
|
376
401
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
402
|
+
throw mutationError;
|
|
403
|
+
} finally {
|
|
404
|
+
if (mountedRef.current) {
|
|
405
|
+
setLoading(false);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}, []);
|
|
380
409
|
|
|
381
410
|
// Reset function
|
|
382
411
|
const reset = useCallback(() => {
|
|
@@ -407,16 +436,16 @@ export interface UseLazyQueryResult<T> {
|
|
|
407
436
|
}
|
|
408
437
|
|
|
409
438
|
/**
|
|
410
|
-
* Execute a query on demand (not on mount)
|
|
439
|
+
* Execute a query on demand (not on mount).
|
|
440
|
+
* Client is automatically injected from LensProvider context.
|
|
411
441
|
*
|
|
412
442
|
* @example
|
|
413
443
|
* ```tsx
|
|
414
444
|
* // Route + Params pattern
|
|
415
445
|
* function SearchUsers() {
|
|
416
|
-
* const client = useLensClient();
|
|
417
446
|
* const [searchTerm, setSearchTerm] = useState('');
|
|
418
447
|
* const { execute, data, loading } = useLazyQuery(
|
|
419
|
-
* client.user.search,
|
|
448
|
+
* (client) => client.user.search,
|
|
420
449
|
* { query: searchTerm }
|
|
421
450
|
* );
|
|
422
451
|
*
|
|
@@ -431,9 +460,8 @@ export interface UseLazyQueryResult<T> {
|
|
|
431
460
|
*
|
|
432
461
|
* // Accessor pattern
|
|
433
462
|
* function LazyComplexQuery({ userId }: { userId: string }) {
|
|
434
|
-
* const client = useLensClient();
|
|
435
463
|
* const { execute, data } = useLazyQuery(
|
|
436
|
-
* () => client.user.get({ id: userId }),
|
|
464
|
+
* (client) => client.user.get({ id: userId }),
|
|
437
465
|
* [userId]
|
|
438
466
|
* );
|
|
439
467
|
* return <button onClick={execute}>Load</button>;
|
|
@@ -443,24 +471,26 @@ export interface UseLazyQueryResult<T> {
|
|
|
443
471
|
|
|
444
472
|
// Overload 1: Route + Params
|
|
445
473
|
export function useLazyQuery<TParams, TResult, TSelected = TResult>(
|
|
446
|
-
|
|
474
|
+
selector: RouteSelector<TParams, TResult>,
|
|
447
475
|
params: TParams,
|
|
448
476
|
options?: UseQueryOptions<TResult, TSelected>,
|
|
449
477
|
): UseLazyQueryResult<TSelected>;
|
|
450
478
|
|
|
451
479
|
// Overload 2: Accessor + Deps
|
|
452
480
|
export function useLazyQuery<TResult, TSelected = TResult>(
|
|
453
|
-
|
|
481
|
+
selector: QuerySelector<TResult>,
|
|
454
482
|
deps: DependencyList,
|
|
455
483
|
options?: UseQueryOptions<TResult, TSelected>,
|
|
456
484
|
): UseLazyQueryResult<TSelected>;
|
|
457
485
|
|
|
458
486
|
// Implementation
|
|
459
487
|
export function useLazyQuery<TParams, TResult, TSelected = TResult>(
|
|
460
|
-
|
|
488
|
+
selector: RouteSelector<TParams, TResult> | QuerySelector<TResult>,
|
|
461
489
|
paramsOrDeps: TParams | DependencyList,
|
|
462
490
|
options?: UseQueryOptions<TResult, TSelected>,
|
|
463
491
|
): UseLazyQueryResult<TSelected> {
|
|
492
|
+
const client = useLensClient();
|
|
493
|
+
|
|
464
494
|
const [data, setData] = useState<TSelected | null>(null);
|
|
465
495
|
const [loading, setLoading] = useState(false);
|
|
466
496
|
const [error, setError] = useState<Error | null>(null);
|
|
@@ -472,8 +502,8 @@ export function useLazyQuery<TParams, TResult, TSelected = TResult>(
|
|
|
472
502
|
const isAccessorMode = Array.isArray(paramsOrDeps);
|
|
473
503
|
|
|
474
504
|
// Store refs for execute (so it uses latest values)
|
|
475
|
-
const
|
|
476
|
-
|
|
505
|
+
const selectorRef = useRef(selector);
|
|
506
|
+
selectorRef.current = selector;
|
|
477
507
|
|
|
478
508
|
const paramsOrDepsRef = useRef(paramsOrDeps);
|
|
479
509
|
paramsOrDepsRef.current = paramsOrDeps;
|
|
@@ -493,10 +523,11 @@ export function useLazyQuery<TParams, TResult, TSelected = TResult>(
|
|
|
493
523
|
let query: QueryResult<TResult> | null | undefined;
|
|
494
524
|
|
|
495
525
|
if (isAccessorMode) {
|
|
496
|
-
const
|
|
497
|
-
query =
|
|
526
|
+
const querySelector = selectorRef.current as QuerySelector<TResult>;
|
|
527
|
+
query = querySelector(client);
|
|
498
528
|
} else {
|
|
499
|
-
const
|
|
529
|
+
const routeSelector = selectorRef.current as RouteSelector<TParams, TResult>;
|
|
530
|
+
const route = routeSelector(client);
|
|
500
531
|
if (route) {
|
|
501
532
|
query = route(paramsOrDepsRef.current as TParams);
|
|
502
533
|
}
|
|
@@ -533,7 +564,7 @@ export function useLazyQuery<TParams, TResult, TSelected = TResult>(
|
|
|
533
564
|
setLoading(false);
|
|
534
565
|
}
|
|
535
566
|
}
|
|
536
|
-
}, [isAccessorMode]);
|
|
567
|
+
}, [client, isAccessorMode]);
|
|
537
568
|
|
|
538
569
|
// Reset function
|
|
539
570
|
const reset = useCallback(() => {
|
package/src/index.ts
CHANGED
|
@@ -16,17 +16,17 @@ export { LensProvider, type LensProviderProps, useLensClient } from "./context.j
|
|
|
16
16
|
// =============================================================================
|
|
17
17
|
|
|
18
18
|
export {
|
|
19
|
-
type MutationFn,
|
|
20
19
|
// Types
|
|
21
|
-
type
|
|
22
|
-
type
|
|
20
|
+
type MutationSelector,
|
|
21
|
+
type QuerySelector,
|
|
22
|
+
type RouteSelector,
|
|
23
23
|
type UseLazyQueryResult,
|
|
24
24
|
type UseMutationResult,
|
|
25
25
|
type UseQueryOptions,
|
|
26
26
|
type UseQueryResult,
|
|
27
|
+
// Query hooks
|
|
27
28
|
useLazyQuery,
|
|
28
29
|
// Mutation hook
|
|
29
30
|
useMutation,
|
|
30
|
-
// Query hooks
|
|
31
31
|
useQuery,
|
|
32
32
|
} from "./hooks.js";
|