@sylphx/lens-react 1.0.2

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 ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@sylphx/lens-react",
3
+ "version": "1.0.2",
4
+ "description": "React bindings for Lens API framework",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "scripts": {
15
+ "build": "bun build ./src/index.ts --outdir ./dist --target browser && bun run build:types",
16
+ "build:types": "tsc --emitDeclarationOnly --outDir ./dist",
17
+ "typecheck": "tsc --noEmit",
18
+ "test": "bun test"
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "src"
23
+ ],
24
+ "keywords": [
25
+ "lens",
26
+ "react",
27
+ "hooks",
28
+ "reactive"
29
+ ],
30
+ "author": "SylphxAI",
31
+ "license": "MIT",
32
+ "dependencies": {
33
+ "@sylphx/lens-client": "workspace:*"
34
+ },
35
+ "peerDependencies": {
36
+ "react": ">=18.0.0"
37
+ },
38
+ "devDependencies": {
39
+ "@happy-dom/global-registrator": "^20.0.10",
40
+ "@testing-library/react": "^16.2.0",
41
+ "@types/react": "^18.3.12",
42
+ "happy-dom": "^17.4.7",
43
+ "react": "^18.3.1",
44
+ "react-dom": "^18.3.1",
45
+ "typescript": "^5.9.3"
46
+ }
47
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * @sylphx/lens-react - Context Provider
3
+ *
4
+ * Provides Lens client to React component tree.
5
+ */
6
+
7
+ import type { LensClient } from "@sylphx/lens-client";
8
+ import { type ReactNode, createContext, useContext } from "react";
9
+
10
+ // =============================================================================
11
+ // Context
12
+ // =============================================================================
13
+
14
+ /**
15
+ * Context for Lens client
16
+ * Using any for internal storage to avoid type constraint issues
17
+ */
18
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
19
+ const LensContext = createContext<LensClient<any, any> | null>(null);
20
+
21
+ // =============================================================================
22
+ // Provider
23
+ // =============================================================================
24
+
25
+ export interface LensProviderProps {
26
+ /** Lens client instance */
27
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
28
+ client: LensClient<any, any>;
29
+ /** Children */
30
+ children: ReactNode;
31
+ }
32
+
33
+ /**
34
+ * Provides Lens client to component tree
35
+ *
36
+ * @example
37
+ * ```tsx
38
+ * import { createClient, http } from '@sylphx/lens-client';
39
+ * import { LensProvider } from '@sylphx/lens-react';
40
+ * import type { AppRouter } from './server';
41
+ *
42
+ * const client = createClient<AppRouter>({
43
+ * transport: http({ url: '/api' }),
44
+ * });
45
+ *
46
+ * function App() {
47
+ * return (
48
+ * <LensProvider client={client}>
49
+ * <UserProfile />
50
+ * </LensProvider>
51
+ * );
52
+ * }
53
+ * ```
54
+ */
55
+ export function LensProvider({ client, children }: LensProviderProps) {
56
+ return <LensContext.Provider value={client}>{children}</LensContext.Provider>;
57
+ }
58
+
59
+ // =============================================================================
60
+ // Hook
61
+ // =============================================================================
62
+
63
+ /**
64
+ * Get Lens client from context
65
+ *
66
+ * @throws Error if used outside LensProvider
67
+ *
68
+ * @example
69
+ * ```tsx
70
+ * function UserProfile({ userId }: { userId: string }) {
71
+ * const client = useLensClient<AppRouter>();
72
+ * const { data } = useQuery(client.user.get({ id: userId }));
73
+ * return <h1>{data?.name}</h1>;
74
+ * }
75
+ * ```
76
+ */
77
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
78
+ export function useLensClient<TRouter = any>(): LensClient<any, any> & TRouter {
79
+ const client = useContext(LensContext);
80
+
81
+ if (!client) {
82
+ throw new Error(
83
+ "useLensClient must be used within a <LensProvider>. " +
84
+ "Make sure to wrap your app with <LensProvider client={client}>.",
85
+ );
86
+ }
87
+
88
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
89
+ return client as LensClient<any, any> & TRouter;
90
+ }
@@ -0,0 +1,358 @@
1
+ /**
2
+ * Tests for React Hooks (Operations-based API)
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { signal } from "@sylphx/lens-client";
7
+ import type { MutationResult, QueryResult } from "@sylphx/lens-client";
8
+ import { act, renderHook, waitFor } from "@testing-library/react";
9
+ import { useLazyQuery, useMutation, useQuery } from "./hooks";
10
+
11
+ // =============================================================================
12
+ // Mock QueryResult
13
+ // =============================================================================
14
+
15
+ function createMockQueryResult<T>(initialValue: T | null = null): QueryResult<T> & {
16
+ _setValue: (value: T) => void;
17
+ _setError: (error: Error) => void;
18
+ } {
19
+ let currentValue = initialValue;
20
+ let currentError: Error | null = null;
21
+ const subscribers: Array<(value: T) => void> = [];
22
+ let resolved = false;
23
+ let resolvePromise: ((value: T) => void) | null = null;
24
+ let rejectPromise: ((error: Error) => void) | null = null;
25
+
26
+ const promise = new Promise<T>((resolve, reject) => {
27
+ resolvePromise = resolve;
28
+ rejectPromise = reject;
29
+ if (initialValue !== null) {
30
+ resolved = true;
31
+ resolve(initialValue);
32
+ }
33
+ });
34
+
35
+ const result = {
36
+ get value() {
37
+ return currentValue;
38
+ },
39
+ signal: signal(currentValue),
40
+ loading: signal(initialValue === null),
41
+ error: signal<Error | null>(null),
42
+ subscribe(callback?: (data: T) => void): () => void {
43
+ if (callback) {
44
+ subscribers.push(callback);
45
+ if (currentValue !== null) {
46
+ callback(currentValue);
47
+ }
48
+ }
49
+ return () => {
50
+ const idx = subscribers.indexOf(callback!);
51
+ if (idx >= 0) subscribers.splice(idx, 1);
52
+ };
53
+ },
54
+ select() {
55
+ return result as unknown as QueryResult<T>;
56
+ },
57
+ then<TResult1 = T, TResult2 = never>(
58
+ onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
59
+ onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
60
+ ): Promise<TResult1 | TResult2> {
61
+ return promise.then(onfulfilled, onrejected);
62
+ },
63
+ // Test helpers
64
+ _setValue(value: T) {
65
+ currentValue = value;
66
+ result.signal.value = value;
67
+ result.loading.value = false;
68
+ result.error.value = null;
69
+ subscribers.forEach((cb) => cb(value));
70
+ if (!resolved && resolvePromise) {
71
+ resolved = true;
72
+ resolvePromise(value);
73
+ }
74
+ },
75
+ _setError(error: Error) {
76
+ currentError = error;
77
+ result.loading.value = false;
78
+ result.error.value = error;
79
+ if (!resolved && rejectPromise) {
80
+ resolved = true;
81
+ rejectPromise(error);
82
+ }
83
+ },
84
+ };
85
+
86
+ return result as QueryResult<T> & {
87
+ _setValue: (value: T) => void;
88
+ _setError: (error: Error) => void;
89
+ };
90
+ }
91
+
92
+ // =============================================================================
93
+ // Tests: useQuery
94
+ // =============================================================================
95
+
96
+ describe("useQuery", () => {
97
+ test("returns loading state initially", () => {
98
+ const mockQuery = createMockQueryResult<{ id: string; name: string }>();
99
+
100
+ const { result } = renderHook(() => useQuery(mockQuery));
101
+
102
+ expect(result.current.loading).toBe(true);
103
+ expect(result.current.data).toBe(null);
104
+ expect(result.current.error).toBe(null);
105
+ });
106
+
107
+ test("returns data when query resolves", async () => {
108
+ const mockQuery = createMockQueryResult<{ id: string; name: string }>();
109
+
110
+ const { result } = renderHook(() => useQuery(mockQuery));
111
+
112
+ // Simulate data loading
113
+ act(() => {
114
+ mockQuery._setValue({ id: "123", name: "John" });
115
+ });
116
+
117
+ await waitFor(() => {
118
+ expect(result.current.loading).toBe(false);
119
+ });
120
+
121
+ expect(result.current.data).toEqual({ id: "123", name: "John" });
122
+ expect(result.current.error).toBe(null);
123
+ });
124
+
125
+ test("returns error when query fails", async () => {
126
+ const mockQuery = createMockQueryResult<{ id: string; name: string }>();
127
+
128
+ const { result } = renderHook(() => useQuery(mockQuery));
129
+
130
+ // Simulate error
131
+ act(() => {
132
+ mockQuery._setError(new Error("Query failed"));
133
+ });
134
+
135
+ await waitFor(() => {
136
+ expect(result.current.loading).toBe(false);
137
+ });
138
+
139
+ expect(result.current.error?.message).toBe("Query failed");
140
+ expect(result.current.data).toBe(null);
141
+ });
142
+
143
+ test("skips query when skip option is true", () => {
144
+ const mockQuery = createMockQueryResult<{ id: string; name: string }>();
145
+
146
+ const { result } = renderHook(() => useQuery(mockQuery, { skip: true }));
147
+
148
+ expect(result.current.loading).toBe(false);
149
+ expect(result.current.data).toBe(null);
150
+ });
151
+
152
+ test("updates when query subscription emits", async () => {
153
+ const mockQuery = createMockQueryResult<{ id: string; name: string }>();
154
+
155
+ const { result } = renderHook(() => useQuery(mockQuery));
156
+
157
+ // First value
158
+ act(() => {
159
+ mockQuery._setValue({ id: "123", name: "John" });
160
+ });
161
+
162
+ await waitFor(() => {
163
+ expect(result.current.data?.name).toBe("John");
164
+ });
165
+
166
+ // Update value via subscription
167
+ act(() => {
168
+ mockQuery._setValue({ id: "123", name: "Jane" });
169
+ });
170
+
171
+ await waitFor(() => {
172
+ expect(result.current.data?.name).toBe("Jane");
173
+ });
174
+ });
175
+ });
176
+
177
+ // =============================================================================
178
+ // Tests: useMutation
179
+ // =============================================================================
180
+
181
+ describe("useMutation", () => {
182
+ test("executes mutation and returns result", async () => {
183
+ const mutationFn = async (input: { name: string }): Promise<
184
+ MutationResult<{ id: string; name: string }>
185
+ > => {
186
+ return {
187
+ data: { id: "new-id", name: input.name },
188
+ };
189
+ };
190
+
191
+ const { result } = renderHook(() => useMutation(mutationFn));
192
+
193
+ expect(result.current.loading).toBe(false);
194
+ expect(result.current.data).toBe(null);
195
+
196
+ let mutationResult: MutationResult<{ id: string; name: string }> | undefined;
197
+ await act(async () => {
198
+ mutationResult = await result.current.mutate({ name: "New User" });
199
+ });
200
+
201
+ expect(mutationResult?.data).toEqual({ id: "new-id", name: "New User" });
202
+ expect(result.current.data).toEqual({ id: "new-id", name: "New User" });
203
+ expect(result.current.loading).toBe(false);
204
+ });
205
+
206
+ test("handles mutation error", async () => {
207
+ const mutationFn = async (_input: { name: string }): Promise<
208
+ MutationResult<{ id: string; name: string }>
209
+ > => {
210
+ throw new Error("Mutation failed");
211
+ };
212
+
213
+ const { result } = renderHook(() => useMutation(mutationFn));
214
+
215
+ await act(async () => {
216
+ try {
217
+ await result.current.mutate({ name: "New User" });
218
+ } catch {
219
+ // Expected error
220
+ }
221
+ });
222
+
223
+ expect(result.current.error?.message).toBe("Mutation failed");
224
+ expect(result.current.loading).toBe(false);
225
+ });
226
+
227
+ test("shows loading state during mutation", async () => {
228
+ let resolveMutation: ((value: MutationResult<{ id: string }>) => void) | null = null;
229
+ const mutationFn = async (_input: { name: string }): Promise<
230
+ MutationResult<{ id: string }>
231
+ > => {
232
+ return new Promise((resolve) => {
233
+ resolveMutation = resolve;
234
+ });
235
+ };
236
+
237
+ const { result } = renderHook(() => useMutation(mutationFn));
238
+
239
+ // Start mutation (don't await)
240
+ let mutationPromise: Promise<MutationResult<{ id: string }>> | undefined;
241
+ act(() => {
242
+ mutationPromise = result.current.mutate({ name: "New User" });
243
+ });
244
+
245
+ // Should be loading
246
+ expect(result.current.loading).toBe(true);
247
+
248
+ // Resolve mutation
249
+ await act(async () => {
250
+ resolveMutation?.({ data: { id: "new-id" } });
251
+ await mutationPromise;
252
+ });
253
+
254
+ expect(result.current.loading).toBe(false);
255
+ });
256
+
257
+ test("reset clears mutation state", async () => {
258
+ const mutationFn = async (input: { name: string }): Promise<
259
+ MutationResult<{ id: string; name: string }>
260
+ > => {
261
+ return { data: { id: "new-id", name: input.name } };
262
+ };
263
+
264
+ const { result } = renderHook(() => useMutation(mutationFn));
265
+
266
+ await act(async () => {
267
+ await result.current.mutate({ name: "New User" });
268
+ });
269
+
270
+ expect(result.current.data).not.toBe(null);
271
+
272
+ act(() => {
273
+ result.current.reset();
274
+ });
275
+
276
+ expect(result.current.data).toBe(null);
277
+ expect(result.current.error).toBe(null);
278
+ expect(result.current.loading).toBe(false);
279
+ });
280
+ });
281
+
282
+ // =============================================================================
283
+ // Tests: useLazyQuery
284
+ // =============================================================================
285
+
286
+ describe("useLazyQuery", () => {
287
+ test("does not execute query on mount", () => {
288
+ const mockQuery = createMockQueryResult<{ id: string; name: string }>();
289
+
290
+ const { result } = renderHook(() => useLazyQuery(mockQuery));
291
+
292
+ expect(result.current.loading).toBe(false);
293
+ expect(result.current.data).toBe(null);
294
+ });
295
+
296
+ test("executes query when execute is called", async () => {
297
+ const mockQuery = createMockQueryResult<{ id: string; name: string }>({
298
+ id: "123",
299
+ name: "John",
300
+ });
301
+
302
+ const { result } = renderHook(() => useLazyQuery(mockQuery));
303
+
304
+ let queryResult: { id: string; name: string } | undefined;
305
+ await act(async () => {
306
+ queryResult = await result.current.execute();
307
+ });
308
+
309
+ expect(queryResult).toEqual({ id: "123", name: "John" });
310
+ expect(result.current.data).toEqual({ id: "123", name: "John" });
311
+ });
312
+
313
+ test("handles query error", async () => {
314
+ // Create a mock query that rejects
315
+ const mockQuery = createMockQueryResult<{ id: string; name: string }>();
316
+
317
+ const { result } = renderHook(() => useLazyQuery(mockQuery));
318
+
319
+ // Set error before execute
320
+ act(() => {
321
+ mockQuery._setError(new Error("Query failed"));
322
+ });
323
+
324
+ // Execute should throw
325
+ await act(async () => {
326
+ try {
327
+ await result.current.execute();
328
+ } catch {
329
+ // Expected error
330
+ }
331
+ });
332
+
333
+ expect(result.current.error?.message).toBe("Query failed");
334
+ });
335
+
336
+ test("reset clears query state", async () => {
337
+ const mockQuery = createMockQueryResult<{ id: string; name: string }>({
338
+ id: "123",
339
+ name: "John",
340
+ });
341
+
342
+ const { result } = renderHook(() => useLazyQuery(mockQuery));
343
+
344
+ await act(async () => {
345
+ await result.current.execute();
346
+ });
347
+
348
+ expect(result.current.data).not.toBe(null);
349
+
350
+ act(() => {
351
+ result.current.reset();
352
+ });
353
+
354
+ expect(result.current.data).toBe(null);
355
+ expect(result.current.error).toBe(null);
356
+ expect(result.current.loading).toBe(false);
357
+ });
358
+ });