@sylphx/lens-react 1.2.13 → 1.2.21

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.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/src/hooks.test.tsx +609 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylphx/lens-react",
3
- "version": "1.2.13",
3
+ "version": "1.2.21",
4
4
  "description": "React bindings for Lens API framework",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -30,7 +30,7 @@
30
30
  "author": "SylphxAI",
31
31
  "license": "MIT",
32
32
  "dependencies": {
33
- "@sylphx/lens-client": "^1.7.5"
33
+ "@sylphx/lens-client": "^1.15.2"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "react": ">=18.0.0"
@@ -149,6 +149,22 @@ describe("useQuery", () => {
149
149
  expect(result.current.data).toBe(null);
150
150
  });
151
151
 
152
+ test("handles non-Error rejection", async () => {
153
+ const mockQuery = {
154
+ subscribe: () => () => {},
155
+ then: (onFulfilled: any, onRejected: any) => {
156
+ // Reject with a string instead of Error
157
+ return Promise.reject("String error").then(onFulfilled, onRejected);
158
+ },
159
+ } as unknown as QueryResult<{ id: string }>;
160
+
161
+ const { result } = renderHook(() => useQuery(mockQuery));
162
+
163
+ await waitFor(() => {
164
+ expect(result.current.error?.message).toBe("String error");
165
+ });
166
+ });
167
+
152
168
  test("skips query when skip option is true", () => {
153
169
  const mockQuery = createMockQueryResult<{ id: string; name: string }>();
154
170
 
@@ -158,6 +174,55 @@ describe("useQuery", () => {
158
174
  expect(result.current.data).toBe(null);
159
175
  });
160
176
 
177
+ test("handles null query", () => {
178
+ const { result } = renderHook(() => useQuery(null));
179
+
180
+ expect(result.current.loading).toBe(false);
181
+ expect(result.current.data).toBe(null);
182
+ expect(result.current.error).toBe(null);
183
+ });
184
+
185
+ test("handles undefined query", () => {
186
+ const { result } = renderHook(() => useQuery(undefined));
187
+
188
+ expect(result.current.loading).toBe(false);
189
+ expect(result.current.data).toBe(null);
190
+ expect(result.current.error).toBe(null);
191
+ });
192
+
193
+ test("handles accessor function returning query", async () => {
194
+ const mockQuery = createMockQueryResult<{ id: string; name: string }>();
195
+ const accessor = () => mockQuery;
196
+
197
+ const { result } = renderHook(() => useQuery(accessor));
198
+
199
+ act(() => {
200
+ mockQuery._setValue({ id: "123", name: "John" });
201
+ });
202
+
203
+ await waitFor(() => {
204
+ expect(result.current.data).toEqual({ id: "123", name: "John" });
205
+ });
206
+ });
207
+
208
+ test("handles accessor function returning null", () => {
209
+ const accessor = () => null;
210
+
211
+ const { result } = renderHook(() => useQuery(accessor));
212
+
213
+ expect(result.current.loading).toBe(false);
214
+ expect(result.current.data).toBe(null);
215
+ });
216
+
217
+ test("handles accessor function returning undefined", () => {
218
+ const accessor = () => undefined;
219
+
220
+ const { result } = renderHook(() => useQuery(accessor));
221
+
222
+ expect(result.current.loading).toBe(false);
223
+ expect(result.current.data).toBe(null);
224
+ });
225
+
161
226
  test("updates when query subscription emits", async () => {
162
227
  const mockQuery = createMockQueryResult<{ id: string; name: string }>();
163
228
 
@@ -181,6 +246,235 @@ describe("useQuery", () => {
181
246
  expect(result.current.data?.name).toBe("Jane");
182
247
  });
183
248
  });
249
+
250
+ test("refetch reloads the query", async () => {
251
+ const mockQuery = createMockQueryResult<{ id: string; name: string }>();
252
+
253
+ const { result } = renderHook(() => useQuery(mockQuery));
254
+
255
+ // Initial load
256
+ act(() => {
257
+ mockQuery._setValue({ id: "123", name: "John" });
258
+ });
259
+
260
+ await waitFor(() => {
261
+ expect(result.current.data?.name).toBe("John");
262
+ });
263
+
264
+ // Refetch should trigger loading state and reload
265
+ act(() => {
266
+ result.current.refetch();
267
+ });
268
+
269
+ // Note: In this mock, refetch will resolve with the same data
270
+ // In a real scenario, it would trigger a new network request
271
+ await waitFor(() => {
272
+ expect(result.current.loading).toBe(false);
273
+ });
274
+
275
+ expect(result.current.data).toEqual({ id: "123", name: "John" });
276
+ });
277
+
278
+ test("refetch handles errors", async () => {
279
+ const mockQuery = createMockQueryResult<{ id: string; name: string }>();
280
+
281
+ const { result } = renderHook(() => useQuery(mockQuery));
282
+
283
+ // Initial load succeeds
284
+ act(() => {
285
+ mockQuery._setValue({ id: "123", name: "John" });
286
+ });
287
+
288
+ await waitFor(() => {
289
+ expect(result.current.data?.name).toBe("John");
290
+ });
291
+
292
+ // Create a new query that will fail
293
+ const failingQuery = {
294
+ subscribe: () => () => {},
295
+ then: (onFulfilled: any, onRejected: any) => {
296
+ return Promise.reject(new Error("Refetch failed")).then(onFulfilled, onRejected);
297
+ },
298
+ } as unknown as QueryResult<{ id: string; name: string }>;
299
+
300
+ // Update the query to use failing query
301
+ const { result: result2 } = renderHook(() => useQuery(failingQuery));
302
+
303
+ await waitFor(() => {
304
+ expect(result2.current.error?.message).toBe("Refetch failed");
305
+ });
306
+ });
307
+
308
+ test("refetch does nothing when query is null", () => {
309
+ const { result } = renderHook(() => useQuery(null));
310
+
311
+ act(() => {
312
+ result.current.refetch();
313
+ });
314
+
315
+ expect(result.current.loading).toBe(false);
316
+ expect(result.current.data).toBe(null);
317
+ });
318
+
319
+ test("refetch does nothing when skip is true", () => {
320
+ const mockQuery = createMockQueryResult<{ id: string; name: string }>();
321
+
322
+ const { result } = renderHook(() => useQuery(mockQuery, { skip: true }));
323
+
324
+ act(() => {
325
+ result.current.refetch();
326
+ });
327
+
328
+ expect(result.current.loading).toBe(false);
329
+ expect(result.current.data).toBe(null);
330
+ });
331
+
332
+ test("refetch with non-Error rejection", async () => {
333
+ let shouldFail = false;
334
+ const mockQuery = {
335
+ subscribe: () => () => {},
336
+ then: (onFulfilled: any, onRejected: any) => {
337
+ if (shouldFail) {
338
+ return Promise.reject("Refetch string error").then(onFulfilled, onRejected);
339
+ }
340
+ return Promise.resolve({ id: "123", name: "John" } as any).then(onFulfilled, onRejected);
341
+ },
342
+ } as unknown as QueryResult<{ id: string; name: string }>;
343
+
344
+ const { result } = renderHook(() => useQuery(mockQuery));
345
+
346
+ await waitFor(() => {
347
+ expect(result.current.data).toEqual({ id: "123", name: "John" });
348
+ });
349
+
350
+ // Make it fail on refetch
351
+ shouldFail = true;
352
+
353
+ act(() => {
354
+ result.current.refetch();
355
+ });
356
+
357
+ await waitFor(() => {
358
+ expect(result.current.error?.message).toBe("Refetch string error");
359
+ });
360
+ });
361
+
362
+ test("cleans up subscription on unmount", async () => {
363
+ const mockQuery = createMockQueryResult<{ id: string; name: string }>();
364
+ let unsubscribeCalled = false;
365
+
366
+ // Override subscribe to track unsubscribe
367
+ const originalSubscribe = mockQuery.subscribe;
368
+ mockQuery.subscribe = (callback?: (data: { id: string; name: string }) => void) => {
369
+ const originalUnsubscribe = originalSubscribe.call(mockQuery, callback);
370
+ return () => {
371
+ unsubscribeCalled = true;
372
+ originalUnsubscribe();
373
+ };
374
+ };
375
+
376
+ const { unmount } = renderHook(() => useQuery(mockQuery));
377
+
378
+ unmount();
379
+
380
+ expect(unsubscribeCalled).toBe(true);
381
+ });
382
+
383
+ test("does not update state after unmount", async () => {
384
+ const mockQuery = createMockQueryResult<{ id: string; name: string }>();
385
+
386
+ const { unmount } = renderHook(() => useQuery(mockQuery));
387
+
388
+ // Unmount before query resolves
389
+ unmount();
390
+
391
+ // Try to set value after unmount
392
+ act(() => {
393
+ mockQuery._setValue({ id: "123", name: "John" });
394
+ });
395
+
396
+ // State should not be updated (we can't really assert this directly,
397
+ // but the test passing without errors shows no state update occurred)
398
+ expect(true).toBe(true);
399
+ });
400
+
401
+ test("handles query change", async () => {
402
+ const mockQuery1 = createMockQueryResult<{ id: string; name: string }>();
403
+ const mockQuery2 = createMockQueryResult<{ id: string; name: string }>();
404
+
405
+ let currentQuery = mockQuery1;
406
+ const { result, rerender } = renderHook(() => useQuery(currentQuery));
407
+
408
+ // Load first query
409
+ act(() => {
410
+ mockQuery1._setValue({ id: "1", name: "First" });
411
+ });
412
+
413
+ await waitFor(() => {
414
+ expect(result.current.data?.name).toBe("First");
415
+ });
416
+
417
+ // Change to second query
418
+ currentQuery = mockQuery2;
419
+ rerender();
420
+
421
+ expect(result.current.loading).toBe(true);
422
+
423
+ // Load second query
424
+ act(() => {
425
+ mockQuery2._setValue({ id: "2", name: "Second" });
426
+ });
427
+
428
+ await waitFor(() => {
429
+ expect(result.current.data?.name).toBe("Second");
430
+ });
431
+ });
432
+
433
+ test("handles skip option change from true to false", async () => {
434
+ const mockQuery = createMockQueryResult<{ id: string; name: string }>();
435
+
436
+ let skip = true;
437
+ const { result, rerender } = renderHook(() => useQuery(mockQuery, { skip }));
438
+
439
+ expect(result.current.loading).toBe(false);
440
+
441
+ // Change skip to false
442
+ skip = false;
443
+ rerender();
444
+
445
+ expect(result.current.loading).toBe(true);
446
+
447
+ act(() => {
448
+ mockQuery._setValue({ id: "123", name: "John" });
449
+ });
450
+
451
+ await waitFor(() => {
452
+ expect(result.current.data).toEqual({ id: "123", name: "John" });
453
+ });
454
+ });
455
+
456
+ test("handles skip option change from false to true", async () => {
457
+ const mockQuery = createMockQueryResult<{ id: string; name: string }>();
458
+
459
+ let skip = false;
460
+ const { result, rerender } = renderHook(() => useQuery(mockQuery, { skip }));
461
+
462
+ act(() => {
463
+ mockQuery._setValue({ id: "123", name: "John" });
464
+ });
465
+
466
+ await waitFor(() => {
467
+ expect(result.current.data).toEqual({ id: "123", name: "John" });
468
+ });
469
+
470
+ // Change skip to true
471
+ skip = true;
472
+ rerender();
473
+
474
+ expect(result.current.loading).toBe(false);
475
+ expect(result.current.data).toBe(null);
476
+ expect(result.current.error).toBe(null);
477
+ });
184
478
  });
185
479
 
186
480
  // =============================================================================
@@ -229,6 +523,25 @@ describe("useMutation", () => {
229
523
  expect(result.current.loading).toBe(false);
230
524
  });
231
525
 
526
+ test("handles non-Error exception in mutation", async () => {
527
+ const mutationFn = async (_input: { name: string }): Promise<MutationResult<{ id: string; name: string }>> => {
528
+ throw "String error";
529
+ };
530
+
531
+ const { result } = renderHook(() => useMutation(mutationFn));
532
+
533
+ await act(async () => {
534
+ try {
535
+ await result.current.mutate({ name: "New User" });
536
+ } catch {
537
+ // Expected error
538
+ }
539
+ });
540
+
541
+ expect(result.current.error?.message).toBe("String error");
542
+ expect(result.current.loading).toBe(false);
543
+ });
544
+
232
545
  test("shows loading state during mutation", async () => {
233
546
  let resolveMutation: ((value: MutationResult<{ id: string }>) => void) | null = null;
234
547
  const mutationFn = async (_input: { name: string }): Promise<MutationResult<{ id: string }>> => {
@@ -278,6 +591,99 @@ describe("useMutation", () => {
278
591
  expect(result.current.error).toBe(null);
279
592
  expect(result.current.loading).toBe(false);
280
593
  });
594
+
595
+ test("handles multiple mutations in sequence", async () => {
596
+ let counter = 0;
597
+ const mutationFn = async (input: { name: string }): Promise<MutationResult<{ id: string; name: string }>> => {
598
+ counter++;
599
+ return { data: { id: `id-${counter}`, name: input.name } };
600
+ };
601
+
602
+ const { result } = renderHook(() => useMutation(mutationFn));
603
+
604
+ // First mutation
605
+ await act(async () => {
606
+ await result.current.mutate({ name: "First" });
607
+ });
608
+
609
+ expect(result.current.data).toEqual({ id: "id-1", name: "First" });
610
+
611
+ // Second mutation
612
+ await act(async () => {
613
+ await result.current.mutate({ name: "Second" });
614
+ });
615
+
616
+ expect(result.current.data).toEqual({ id: "id-2", name: "Second" });
617
+ });
618
+
619
+ test("clears error on successful mutation after previous error", async () => {
620
+ let shouldFail = true;
621
+ const mutationFn = async (input: { name: string }): Promise<MutationResult<{ id: string; name: string }>> => {
622
+ if (shouldFail) {
623
+ throw new Error("Mutation failed");
624
+ }
625
+ return { data: { id: "new-id", name: input.name } };
626
+ };
627
+
628
+ const { result } = renderHook(() => useMutation(mutationFn));
629
+
630
+ // First mutation fails
631
+ await act(async () => {
632
+ try {
633
+ await result.current.mutate({ name: "New User" });
634
+ } catch {
635
+ // Expected error
636
+ }
637
+ });
638
+
639
+ expect(result.current.error?.message).toBe("Mutation failed");
640
+
641
+ // Second mutation succeeds
642
+ shouldFail = false;
643
+ await act(async () => {
644
+ await result.current.mutate({ name: "New User" });
645
+ });
646
+
647
+ expect(result.current.error).toBe(null);
648
+ expect(result.current.data).toEqual({ id: "new-id", name: "New User" });
649
+ });
650
+
651
+ test("does not update state after unmount", async () => {
652
+ const mutationFn = async (input: { name: string }): Promise<MutationResult<{ id: string; name: string }>> => {
653
+ return { data: { id: "new-id", name: input.name } };
654
+ };
655
+
656
+ const { result, unmount } = renderHook(() => useMutation(mutationFn));
657
+
658
+ // Start mutation but unmount before it completes
659
+ const mutationPromise = result.current.mutate({ name: "New User" });
660
+ unmount();
661
+
662
+ // Wait for mutation to complete
663
+ await mutationPromise;
664
+
665
+ // Test passes if no error is thrown (state update after unmount would cause error)
666
+ expect(true).toBe(true);
667
+ });
668
+
669
+ test("mutation result includes rollback function when provided", async () => {
670
+ const rollbackFn = () => console.log("Rollback");
671
+ const mutationFn = async (input: { name: string }): Promise<MutationResult<{ id: string; name: string }>> => {
672
+ return {
673
+ data: { id: "new-id", name: input.name },
674
+ rollback: rollbackFn,
675
+ };
676
+ };
677
+
678
+ const { result } = renderHook(() => useMutation(mutationFn));
679
+
680
+ let mutationResult: MutationResult<{ id: string; name: string }> | undefined;
681
+ await act(async () => {
682
+ mutationResult = await result.current.mutate({ name: "New User" });
683
+ });
684
+
685
+ expect(mutationResult?.rollback).toBe(rollbackFn);
686
+ });
281
687
  });
282
688
 
283
689
  // =============================================================================
@@ -334,6 +740,26 @@ describe("useLazyQuery", () => {
334
740
  expect(result.current.error?.message).toBe("Query failed");
335
741
  });
336
742
 
743
+ test("handles non-Error rejection", async () => {
744
+ const mockQuery = {
745
+ then: (onFulfilled: any, onRejected: any) => {
746
+ return Promise.reject("String error").then(onFulfilled, onRejected);
747
+ },
748
+ } as unknown as QueryResult<{ id: string }>;
749
+
750
+ const { result } = renderHook(() => useLazyQuery(mockQuery));
751
+
752
+ await act(async () => {
753
+ try {
754
+ await result.current.execute();
755
+ } catch {
756
+ // Expected error
757
+ }
758
+ });
759
+
760
+ expect(result.current.error?.message).toBe("String error");
761
+ });
762
+
337
763
  test("reset clears query state", async () => {
338
764
  const mockQuery = createMockQueryResult<{ id: string; name: string }>({
339
765
  id: "123",
@@ -356,4 +782,187 @@ describe("useLazyQuery", () => {
356
782
  expect(result.current.error).toBe(null);
357
783
  expect(result.current.loading).toBe(false);
358
784
  });
785
+
786
+ test("handles null query", async () => {
787
+ const { result } = renderHook(() => useLazyQuery(null));
788
+
789
+ let queryResult: any;
790
+ await act(async () => {
791
+ queryResult = await result.current.execute();
792
+ });
793
+
794
+ expect(queryResult).toBe(null);
795
+ expect(result.current.data).toBe(null);
796
+ expect(result.current.loading).toBe(false);
797
+ });
798
+
799
+ test("handles undefined query", async () => {
800
+ const { result } = renderHook(() => useLazyQuery(undefined));
801
+
802
+ let queryResult: any;
803
+ await act(async () => {
804
+ queryResult = await result.current.execute();
805
+ });
806
+
807
+ expect(queryResult).toBe(null);
808
+ expect(result.current.data).toBe(null);
809
+ expect(result.current.loading).toBe(false);
810
+ });
811
+
812
+ test("handles accessor function returning query", async () => {
813
+ const mockQuery = createMockQueryResult<{ id: string; name: string }>({
814
+ id: "123",
815
+ name: "John",
816
+ });
817
+ const accessor = () => mockQuery;
818
+
819
+ const { result } = renderHook(() => useLazyQuery(accessor));
820
+
821
+ let queryResult: { id: string; name: string } | undefined;
822
+ await act(async () => {
823
+ queryResult = await result.current.execute();
824
+ });
825
+
826
+ expect(queryResult).toEqual({ id: "123", name: "John" });
827
+ });
828
+
829
+ test("handles accessor function returning null", async () => {
830
+ const accessor = () => null;
831
+
832
+ const { result } = renderHook(() => useLazyQuery(accessor));
833
+
834
+ let queryResult: any;
835
+ await act(async () => {
836
+ queryResult = await result.current.execute();
837
+ });
838
+
839
+ expect(queryResult).toBe(null);
840
+ });
841
+
842
+ test("uses latest query value from accessor on execute", async () => {
843
+ let currentValue = "first";
844
+ const mockQuery1 = createMockQueryResult<string>("first");
845
+ const mockQuery2 = createMockQueryResult<string>("second");
846
+
847
+ const accessor = () => (currentValue === "first" ? mockQuery1 : mockQuery2);
848
+
849
+ const { result } = renderHook(() => useLazyQuery(accessor));
850
+
851
+ // First execute
852
+ let queryResult1: string | undefined;
853
+ await act(async () => {
854
+ queryResult1 = await result.current.execute();
855
+ });
856
+
857
+ expect(queryResult1).toBe("first");
858
+
859
+ // Change accessor to return different query
860
+ currentValue = "second";
861
+
862
+ // Second execute should use new query
863
+ let queryResult2: string | undefined;
864
+ await act(async () => {
865
+ queryResult2 = await result.current.execute();
866
+ });
867
+
868
+ expect(queryResult2).toBe("second");
869
+ });
870
+
871
+ test("shows loading state during execution", async () => {
872
+ const mockQuery = createMockQueryResult<{ id: string }>();
873
+
874
+ const { result } = renderHook(() => useLazyQuery(mockQuery));
875
+
876
+ // Execute and set value
877
+ let executePromise: Promise<{ id: string }>;
878
+ await act(async () => {
879
+ executePromise = result.current.execute();
880
+ mockQuery._setValue({ id: "123" });
881
+ await executePromise;
882
+ });
883
+
884
+ expect(result.current.loading).toBe(false);
885
+ expect(result.current.data).toEqual({ id: "123" });
886
+ });
887
+
888
+ test("does not update state after unmount", async () => {
889
+ const mockQuery = createMockQueryResult<{ id: string; name: string }>();
890
+
891
+ const { result, unmount } = renderHook(() => useLazyQuery(mockQuery));
892
+
893
+ // Start execution, unmount, then resolve
894
+ const executePromise = result.current.execute();
895
+ unmount();
896
+
897
+ // Resolve after unmount
898
+ await act(async () => {
899
+ mockQuery._setValue({ id: "123", name: "John" });
900
+ await executePromise;
901
+ });
902
+
903
+ // Test passes if no error is thrown (state update after unmount would cause error)
904
+ expect(true).toBe(true);
905
+ });
906
+
907
+ test("clears error on successful execute after previous error", async () => {
908
+ const mockQuery1 = createMockQueryResult<{ id: string }>();
909
+ const mockQuery2 = createMockQueryResult<{ id: string }>({ id: "123" });
910
+
911
+ const { result, rerender } = renderHook(({ query }) => useLazyQuery(query), {
912
+ initialProps: { query: mockQuery1 },
913
+ });
914
+
915
+ // First execution fails
916
+ await act(async () => {
917
+ const executePromise = result.current.execute();
918
+ mockQuery1._setError(new Error("Query failed"));
919
+ try {
920
+ await executePromise;
921
+ } catch {
922
+ // Expected error
923
+ }
924
+ });
925
+
926
+ expect(result.current.error?.message).toBe("Query failed");
927
+
928
+ // Switch to successful query
929
+ rerender({ query: mockQuery2 });
930
+
931
+ // Second execution succeeds
932
+ await act(async () => {
933
+ await result.current.execute();
934
+ });
935
+
936
+ expect(result.current.error).toBe(null);
937
+ expect(result.current.data).toEqual({ id: "123" });
938
+ });
939
+
940
+ test("can execute multiple times", async () => {
941
+ const mockQuery1 = createMockQueryResult<{ count: number }>();
942
+ const mockQuery2 = createMockQueryResult<{ count: number }>();
943
+
944
+ const { result, rerender } = renderHook(({ query }) => useLazyQuery(query), {
945
+ initialProps: { query: mockQuery1 },
946
+ });
947
+
948
+ // First execution
949
+ await act(async () => {
950
+ const executePromise = result.current.execute();
951
+ mockQuery1._setValue({ count: 1 });
952
+ await executePromise;
953
+ });
954
+
955
+ expect(result.current.data?.count).toBe(1);
956
+
957
+ // Change to second query and execute again
958
+ rerender({ query: mockQuery2 });
959
+
960
+ await act(async () => {
961
+ const executePromise = result.current.execute();
962
+ mockQuery2._setValue({ count: 2 });
963
+ await executePromise;
964
+ });
965
+
966
+ expect(result.current.data?.count).toBe(2);
967
+ });
359
968
  });