@sylphx/lens-react 2.0.2 → 2.1.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/src/hooks.ts CHANGED
@@ -6,27 +6,29 @@
6
6
  *
7
7
  * @example
8
8
  * ```tsx
9
- * import { useLensClient, useQuery, useMutation } from '@sylphx/lens-react';
9
+ * import { useQuery, useMutation } from '@sylphx/lens-react';
10
10
  *
11
11
  * function UserProfile({ userId }: { userId: string }) {
12
- * const client = useLensClient();
13
- * // Route + Params pattern (recommended)
14
- * const { data: user, loading } = useQuery(client.user.get, { id: userId });
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 client = useLensClient();
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
- /** Route function type - takes params and returns QueryResult */
70
- export type RouteFunction<TParams, TResult> = (params: TParams) => QueryResult<TResult>;
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
- /** Accessor function type - returns QueryResult or null */
73
- export type QueryAccessor<T> = () => QueryResult<T> | null | undefined;
79
+ /** Query accessor selector - callback that returns QueryResult */
80
+ export type QuerySelector<TResult> = (client: Client) => QueryResult<TResult> | null | undefined;
74
81
 
75
- /** Mutation function type */
76
- export type MutationFn<TInput, TOutput> = (input: TInput) => Promise<MutationResult<TOutput>>;
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 - Route + Params (Primary API)
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 }).pipe(transform), [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 client = useLensClient();
102
- * const { data: user, loading, error } = useQuery(client.user.get, { id: userId });
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 client = useLensClient();
112
- * const { data: name } = useQuery(client.user.get, { id: userId }, {
113
- * select: (user) => user.name
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 client = useLensClient();
131
- * const { data } = useQuery(client.user.get, { id: userId }, { skip: !shouldFetch });
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
- route: RouteFunction<TParams, TResult> | null,
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
- accessor: QueryAccessor<TResult>,
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
- routeOrAccessor: RouteFunction<TParams, TResult> | QueryAccessor<TResult> | null,
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: call the function
178
- const accessor = routeOrAccessor as QueryAccessor<TResult>;
179
- return accessor();
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
- if (!routeOrAccessor) return null;
183
- const route = routeOrAccessor as RouteFunction<TParams, TResult>;
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
- [routeOrAccessor, paramsKey, options?.skip],
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
- // Subscribe to updates
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 initial load via promise (for one-shot queries)
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 mutationFn - Mutation function from client API
318
+ * @param selector - Callback that returns mutation function from client
294
319
  *
295
320
  * @example
296
321
  * ```tsx
297
322
  * function CreatePost() {
298
- * const client = useLensClient();
299
- * const { mutate, loading, error, data } = useMutation(client.post.create);
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 client = useLensClient();
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
- mutationFn: MutationFn<TInput, TOutput>,
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
- async (input: TInput): Promise<MutationResult<TOutput>> => {
355
- setLoading(true);
356
- setError(null);
385
+ const mutate = useCallback(async (input: TInput): Promise<MutationResult<TOutput>> => {
386
+ setLoading(true);
387
+ setError(null);
357
388
 
358
- try {
359
- const result = await mutationFn(input);
389
+ try {
390
+ const result = await mutationRef.current(input);
360
391
 
361
- if (mountedRef.current) {
362
- setData(result.data);
363
- }
392
+ if (mountedRef.current) {
393
+ setData(result.data);
394
+ }
364
395
 
365
- return result;
366
- } catch (err) {
367
- const mutationError = err instanceof Error ? err : new Error(String(err));
368
- if (mountedRef.current) {
369
- setError(mutationError);
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
- [mutationFn],
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
- route: RouteFunction<TParams, TResult> | null,
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
- accessor: QueryAccessor<TResult>,
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
- routeOrAccessor: RouteFunction<TParams, TResult> | QueryAccessor<TResult> | null,
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 routeOrAccessorRef = useRef(routeOrAccessor);
476
- routeOrAccessorRef.current = routeOrAccessor;
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 accessor = routeOrAccessorRef.current as QueryAccessor<TResult>;
497
- query = accessor();
526
+ const querySelector = selectorRef.current as QuerySelector<TResult>;
527
+ query = querySelector(client);
498
528
  } else {
499
- const route = routeOrAccessorRef.current as RouteFunction<TParams, TResult> | null;
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 QueryAccessor,
22
- type RouteFunction,
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";