@sylphx/lens-react 2.1.6 → 2.2.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/README.md +53 -15
- package/dist/index.d.ts +136 -1
- package/dist/index.js +287 -37
- package/package.json +3 -3
- package/src/create.tsx +573 -0
- package/src/index.ts +35 -3
package/README.md
CHANGED
|
@@ -5,34 +5,72 @@ React hooks for the Lens API framework.
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
bun add @sylphx/lens-react
|
|
8
|
+
bun add @sylphx/lens-react @sylphx/lens-client
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
## Usage
|
|
12
12
|
|
|
13
|
+
### Setup Client
|
|
14
|
+
|
|
13
15
|
```typescript
|
|
14
|
-
|
|
15
|
-
import {
|
|
16
|
+
// lib/client.ts
|
|
17
|
+
import { createClient } from "@sylphx/lens-react";
|
|
18
|
+
import { http } from "@sylphx/lens-client";
|
|
19
|
+
import type { AppRouter } from "@/server/router";
|
|
16
20
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
</LensProvider>
|
|
22
|
-
);
|
|
23
|
-
}
|
|
21
|
+
export const client = createClient<AppRouter>({
|
|
22
|
+
transport: http({ url: "/api/lens" }),
|
|
23
|
+
});
|
|
24
|
+
```
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
### Query (in component)
|
|
27
|
+
|
|
28
|
+
```tsx
|
|
29
|
+
import { client } from "@/lib/client";
|
|
30
|
+
|
|
31
|
+
function UserProfile({ id }: { id: string }) {
|
|
32
|
+
const { data, loading, error, refetch } = client.user.get({
|
|
33
|
+
input: { id },
|
|
34
|
+
select: { name: true, email: true },
|
|
35
|
+
});
|
|
28
36
|
|
|
29
37
|
if (loading) return <div>Loading...</div>;
|
|
30
38
|
if (error) return <div>Error: {error.message}</div>;
|
|
31
39
|
|
|
32
|
-
return <div>{data
|
|
40
|
+
return <div>{data?.name}</div>;
|
|
33
41
|
}
|
|
34
42
|
```
|
|
35
43
|
|
|
44
|
+
### Mutation (in component)
|
|
45
|
+
|
|
46
|
+
```tsx
|
|
47
|
+
import { client } from "@/lib/client";
|
|
48
|
+
|
|
49
|
+
function CreateUser() {
|
|
50
|
+
const { mutate, loading, error, data, reset } = client.user.create({
|
|
51
|
+
onSuccess: (data) => console.log("Created:", data),
|
|
52
|
+
onError: (error) => console.error("Failed:", error),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const handleSubmit = async () => {
|
|
56
|
+
await mutate({ input: { name: "New User" } });
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<button onClick={handleSubmit} disabled={loading}>
|
|
61
|
+
{loading ? "Creating..." : "Create User"}
|
|
62
|
+
</button>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### SSR / Server-side
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
// Use .fetch() for promise-based calls
|
|
71
|
+
const user = await client.user.get.fetch({ input: { id } });
|
|
72
|
+
```
|
|
73
|
+
|
|
36
74
|
## License
|
|
37
75
|
|
|
38
76
|
MIT
|
|
@@ -41,4 +79,4 @@ MIT
|
|
|
41
79
|
|
|
42
80
|
Built with [@sylphx/lens-client](https://github.com/SylphxAI/Lens).
|
|
43
81
|
|
|
44
|
-
|
|
82
|
+
Powered by Sylphx
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,138 @@
|
|
|
1
|
+
import { LensClientConfig, SelectionObject, TypedClientConfig } from "@sylphx/lens-client";
|
|
2
|
+
import { MutationDef, QueryDef, RouterDef, RouterRoutes } from "@sylphx/lens-core";
|
|
3
|
+
/** Query hook options */
|
|
4
|
+
interface QueryHookOptions<TInput> {
|
|
5
|
+
/** Query input parameters */
|
|
6
|
+
input?: TInput;
|
|
7
|
+
/** Field selection */
|
|
8
|
+
select?: SelectionObject;
|
|
9
|
+
/** Skip query execution */
|
|
10
|
+
skip?: boolean;
|
|
11
|
+
}
|
|
12
|
+
/** Query hook result */
|
|
13
|
+
interface QueryHookResult<T> {
|
|
14
|
+
/** Query data (null if loading or error) */
|
|
15
|
+
data: T | null;
|
|
16
|
+
/** Loading state */
|
|
17
|
+
loading: boolean;
|
|
18
|
+
/** Error state */
|
|
19
|
+
error: Error | null;
|
|
20
|
+
/** Refetch the query */
|
|
21
|
+
refetch: () => void;
|
|
22
|
+
}
|
|
23
|
+
/** Mutation hook options */
|
|
24
|
+
interface MutationHookOptions<TOutput> {
|
|
25
|
+
/** Called on successful mutation */
|
|
26
|
+
onSuccess?: (data: TOutput) => void;
|
|
27
|
+
/** Called on mutation error */
|
|
28
|
+
onError?: (error: Error) => void;
|
|
29
|
+
/** Called when mutation settles (success or error) */
|
|
30
|
+
onSettled?: () => void;
|
|
31
|
+
}
|
|
32
|
+
/** Mutation hook result */
|
|
33
|
+
interface MutationHookResult<
|
|
34
|
+
TInput,
|
|
35
|
+
TOutput
|
|
36
|
+
> {
|
|
37
|
+
/** Execute the mutation */
|
|
38
|
+
mutate: (options: {
|
|
39
|
+
input: TInput;
|
|
40
|
+
select?: SelectionObject;
|
|
41
|
+
}) => Promise<TOutput>;
|
|
42
|
+
/** Mutation is in progress */
|
|
43
|
+
loading: boolean;
|
|
44
|
+
/** Mutation error */
|
|
45
|
+
error: Error | null;
|
|
46
|
+
/** Last mutation result */
|
|
47
|
+
data: TOutput | null;
|
|
48
|
+
/** Reset mutation state */
|
|
49
|
+
reset: () => void;
|
|
50
|
+
}
|
|
51
|
+
/** Query endpoint type */
|
|
52
|
+
interface QueryEndpoint<
|
|
53
|
+
TInput,
|
|
54
|
+
TOutput
|
|
55
|
+
> {
|
|
56
|
+
/** Hook call (in component) */
|
|
57
|
+
<_TSelect extends SelectionObject = Record<string, never>>(options: TInput extends void ? QueryHookOptions<void> | void : QueryHookOptions<TInput>): QueryHookResult<TOutput>;
|
|
58
|
+
/** Promise call (SSR) */
|
|
59
|
+
fetch: <TSelect extends SelectionObject = Record<string, never>>(options: TInput extends void ? {
|
|
60
|
+
input?: void;
|
|
61
|
+
select?: TSelect;
|
|
62
|
+
} | void : {
|
|
63
|
+
input: TInput;
|
|
64
|
+
select?: TSelect;
|
|
65
|
+
}) => Promise<TOutput>;
|
|
66
|
+
}
|
|
67
|
+
/** Mutation endpoint type */
|
|
68
|
+
interface MutationEndpoint<
|
|
69
|
+
TInput,
|
|
70
|
+
TOutput
|
|
71
|
+
> {
|
|
72
|
+
/** Hook call (in component) */
|
|
73
|
+
(options?: MutationHookOptions<TOutput>): MutationHookResult<TInput, TOutput>;
|
|
74
|
+
/** Promise call (SSR) */
|
|
75
|
+
fetch: <TSelect extends SelectionObject = Record<string, never>>(options: {
|
|
76
|
+
input: TInput;
|
|
77
|
+
select?: TSelect;
|
|
78
|
+
}) => Promise<TOutput>;
|
|
79
|
+
}
|
|
80
|
+
/** Infer client type from router routes */
|
|
81
|
+
type InferTypedClient<TRoutes extends RouterRoutes> = { [K in keyof TRoutes] : TRoutes[K] extends RouterDef<infer TNestedRoutes> ? InferTypedClient<TNestedRoutes> : TRoutes[K] extends QueryDef<infer TInput, infer TOutput> ? QueryEndpoint<TInput, TOutput> : TRoutes[K] extends MutationDef<infer TInput, infer TOutput> ? MutationEndpoint<TInput, TOutput> : never };
|
|
82
|
+
/** Typed client from router */
|
|
83
|
+
type TypedClient<TRouter extends RouterDef> = TRouter extends RouterDef<infer TRoutes> ? InferTypedClient<TRoutes> : never;
|
|
84
|
+
/**
|
|
85
|
+
* Create a Lens client with React hooks.
|
|
86
|
+
*
|
|
87
|
+
* Each endpoint can be called:
|
|
88
|
+
* - Directly as a hook: `client.user.get({ input: { id } })`
|
|
89
|
+
* - Via .fetch() for promises: `await client.user.get.fetch({ input: { id } })`
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* ```tsx
|
|
93
|
+
* // lib/client.ts
|
|
94
|
+
* import { createClient } from '@sylphx/lens-react';
|
|
95
|
+
* import { httpTransport } from '@sylphx/lens-client';
|
|
96
|
+
* import type { AppRouter } from '@/server/router';
|
|
97
|
+
*
|
|
98
|
+
* const client = createClient<AppRouter>({
|
|
99
|
+
* transport: httpTransport({ url: '/api/lens' }),
|
|
100
|
+
* });
|
|
101
|
+
*
|
|
102
|
+
* // Component usage
|
|
103
|
+
* function UserProfile({ id }: { id: string }) {
|
|
104
|
+
* // Query hook - auto-subscribes
|
|
105
|
+
* const { data, loading, error } = client.user.get({
|
|
106
|
+
* input: { id },
|
|
107
|
+
* select: { name: true },
|
|
108
|
+
* });
|
|
109
|
+
*
|
|
110
|
+
* // Mutation hook - returns mutate function
|
|
111
|
+
* const { mutate, loading: saving } = client.user.update({
|
|
112
|
+
* onSuccess: () => toast('Updated!'),
|
|
113
|
+
* });
|
|
114
|
+
*
|
|
115
|
+
* if (loading) return <Spinner />;
|
|
116
|
+
* return (
|
|
117
|
+
* <div>
|
|
118
|
+
* <h1>{data?.name}</h1>
|
|
119
|
+
* <button onClick={() => mutate({ input: { id, name: 'New' } })}>
|
|
120
|
+
* Update
|
|
121
|
+
* </button>
|
|
122
|
+
* </div>
|
|
123
|
+
* );
|
|
124
|
+
* }
|
|
125
|
+
*
|
|
126
|
+
* // SSR usage
|
|
127
|
+
* async function UserPage({ id }: { id: string }) {
|
|
128
|
+
* const user = await client.user.get.fetch({ input: { id } });
|
|
129
|
+
* return <div>{user.name}</div>;
|
|
130
|
+
* }
|
|
131
|
+
* ```
|
|
132
|
+
*/
|
|
133
|
+
declare function createClient<TRouter extends RouterDef>(config: LensClientConfig | TypedClientConfig<{
|
|
134
|
+
router: TRouter;
|
|
135
|
+
}>): TypedClient<TRouter>;
|
|
1
136
|
import { LensClient } from "@sylphx/lens-client";
|
|
2
137
|
import { ReactElement, ReactNode } from "react";
|
|
3
138
|
interface LensProviderProps {
|
|
@@ -278,4 +413,4 @@ declare function useLazyQuery<
|
|
|
278
413
|
TResult,
|
|
279
414
|
TSelected = TResult
|
|
280
415
|
>(selector: QuerySelector<TResult>, deps: DependencyList, options?: UseQueryOptions<TResult, TSelected>): UseLazyQueryResult<TSelected>;
|
|
281
|
-
export { useQuery, useMutation, useLensClient, useLazyQuery, UseQueryResult, UseQueryOptions, UseMutationResult, UseLazyQueryResult, RouteSelector, QuerySelector, MutationSelector, LensProviderProps, LensProvider };
|
|
416
|
+
export { useQuery, useMutation, useLensClient, useLazyQuery, createClient, UseQueryResult, UseQueryOptions, UseMutationResult, UseLazyQueryResult, TypedClient, RouteSelector, QuerySelector, QueryHookResult, QueryHookOptions, QueryEndpoint, MutationSelector, MutationHookResult, MutationHookOptions, MutationEndpoint, LensProviderProps, LensProvider };
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,255 @@
|
|
|
1
|
+
// src/create.tsx
|
|
2
|
+
import {
|
|
3
|
+
createClient as createBaseClient
|
|
4
|
+
} from "@sylphx/lens-client";
|
|
5
|
+
import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
|
|
6
|
+
import * as React from "react";
|
|
7
|
+
function queryReducer(state, action) {
|
|
8
|
+
switch (action.type) {
|
|
9
|
+
case "RESET":
|
|
10
|
+
return { data: null, loading: false, error: null };
|
|
11
|
+
case "START":
|
|
12
|
+
return { ...state, loading: true, error: null };
|
|
13
|
+
case "SUCCESS":
|
|
14
|
+
return { data: action.data, loading: false, error: null };
|
|
15
|
+
case "ERROR":
|
|
16
|
+
return { ...state, loading: false, error: action.error };
|
|
17
|
+
case "LOADING_DONE":
|
|
18
|
+
return { ...state, loading: false };
|
|
19
|
+
default:
|
|
20
|
+
return state;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function createQueryHook(baseClient, path) {
|
|
24
|
+
let cachedHook = null;
|
|
25
|
+
const getEndpoint = (p) => {
|
|
26
|
+
const parts = p.split(".");
|
|
27
|
+
let current = baseClient;
|
|
28
|
+
for (const part of parts) {
|
|
29
|
+
current = current[part];
|
|
30
|
+
}
|
|
31
|
+
return current;
|
|
32
|
+
};
|
|
33
|
+
const useQueryHook = (options) => {
|
|
34
|
+
const _optionsKey = JSON.stringify(options ?? {});
|
|
35
|
+
const query = useMemo(() => {
|
|
36
|
+
if (options?.skip)
|
|
37
|
+
return null;
|
|
38
|
+
const endpoint2 = getEndpoint(path);
|
|
39
|
+
return endpoint2({ input: options?.input, select: options?.select });
|
|
40
|
+
}, [options?.input, options?.select, options?.skip, path]);
|
|
41
|
+
const initialState = {
|
|
42
|
+
data: null,
|
|
43
|
+
loading: query != null && !options?.skip,
|
|
44
|
+
error: null
|
|
45
|
+
};
|
|
46
|
+
const [state, dispatch] = useReducer(queryReducer, initialState);
|
|
47
|
+
const mountedRef = useRef(true);
|
|
48
|
+
const queryRef = useRef(query);
|
|
49
|
+
queryRef.current = query;
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
mountedRef.current = true;
|
|
52
|
+
if (query == null) {
|
|
53
|
+
dispatch({ type: "RESET" });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
dispatch({ type: "START" });
|
|
57
|
+
let hasReceivedData = false;
|
|
58
|
+
const unsubscribe = query.subscribe((value) => {
|
|
59
|
+
if (mountedRef.current) {
|
|
60
|
+
hasReceivedData = true;
|
|
61
|
+
dispatch({ type: "SUCCESS", data: value });
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
query.then((value) => {
|
|
65
|
+
if (mountedRef.current) {
|
|
66
|
+
if (!hasReceivedData) {
|
|
67
|
+
dispatch({ type: "SUCCESS", data: value });
|
|
68
|
+
} else {
|
|
69
|
+
dispatch({ type: "LOADING_DONE" });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}, (err) => {
|
|
73
|
+
if (mountedRef.current) {
|
|
74
|
+
dispatch({
|
|
75
|
+
type: "ERROR",
|
|
76
|
+
error: err instanceof Error ? err : new Error(String(err))
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
return () => {
|
|
81
|
+
mountedRef.current = false;
|
|
82
|
+
unsubscribe();
|
|
83
|
+
};
|
|
84
|
+
}, [query]);
|
|
85
|
+
const refetch = useCallback(() => {
|
|
86
|
+
const currentQuery = queryRef.current;
|
|
87
|
+
if (currentQuery == null)
|
|
88
|
+
return;
|
|
89
|
+
dispatch({ type: "START" });
|
|
90
|
+
currentQuery.then((value) => {
|
|
91
|
+
if (mountedRef.current) {
|
|
92
|
+
dispatch({ type: "SUCCESS", data: value });
|
|
93
|
+
}
|
|
94
|
+
}, (err) => {
|
|
95
|
+
if (mountedRef.current) {
|
|
96
|
+
dispatch({
|
|
97
|
+
type: "ERROR",
|
|
98
|
+
error: err instanceof Error ? err : new Error(String(err))
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}, []);
|
|
103
|
+
return { data: state.data, loading: state.loading, error: state.error, refetch };
|
|
104
|
+
};
|
|
105
|
+
const fetch = async (options) => {
|
|
106
|
+
const endpoint2 = getEndpoint(path);
|
|
107
|
+
const queryResult = endpoint2({ input: options?.input, select: options?.select });
|
|
108
|
+
return queryResult.then((data) => data);
|
|
109
|
+
};
|
|
110
|
+
const endpoint = useQueryHook;
|
|
111
|
+
endpoint.fetch = fetch;
|
|
112
|
+
cachedHook = endpoint;
|
|
113
|
+
return cachedHook;
|
|
114
|
+
}
|
|
115
|
+
function createMutationHook(baseClient, path) {
|
|
116
|
+
let cachedHook = null;
|
|
117
|
+
const getEndpoint = (p) => {
|
|
118
|
+
const parts = p.split(".");
|
|
119
|
+
let current = baseClient;
|
|
120
|
+
for (const part of parts) {
|
|
121
|
+
current = current[part];
|
|
122
|
+
}
|
|
123
|
+
return current;
|
|
124
|
+
};
|
|
125
|
+
const useMutationHook = (hookOptions) => {
|
|
126
|
+
const [loading, setLoading] = React.useState(false);
|
|
127
|
+
const [error, setError] = React.useState(null);
|
|
128
|
+
const [data, setData] = React.useState(null);
|
|
129
|
+
const mountedRef = useRef(true);
|
|
130
|
+
const hookOptionsRef = useRef(hookOptions);
|
|
131
|
+
hookOptionsRef.current = hookOptions;
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
mountedRef.current = true;
|
|
134
|
+
return () => {
|
|
135
|
+
mountedRef.current = false;
|
|
136
|
+
};
|
|
137
|
+
}, []);
|
|
138
|
+
const mutate = useCallback(async (options) => {
|
|
139
|
+
setLoading(true);
|
|
140
|
+
setError(null);
|
|
141
|
+
try {
|
|
142
|
+
const endpoint2 = getEndpoint(path);
|
|
143
|
+
const result = await endpoint2({ input: options.input, select: options.select });
|
|
144
|
+
const mutationResult = result;
|
|
145
|
+
if (mountedRef.current) {
|
|
146
|
+
setData(mutationResult.data);
|
|
147
|
+
setLoading(false);
|
|
148
|
+
}
|
|
149
|
+
hookOptionsRef.current?.onSuccess?.(mutationResult.data);
|
|
150
|
+
hookOptionsRef.current?.onSettled?.();
|
|
151
|
+
return mutationResult.data;
|
|
152
|
+
} catch (err) {
|
|
153
|
+
const mutationError = err instanceof Error ? err : new Error(String(err));
|
|
154
|
+
if (mountedRef.current) {
|
|
155
|
+
setError(mutationError);
|
|
156
|
+
setLoading(false);
|
|
157
|
+
}
|
|
158
|
+
hookOptionsRef.current?.onError?.(mutationError);
|
|
159
|
+
hookOptionsRef.current?.onSettled?.();
|
|
160
|
+
throw mutationError;
|
|
161
|
+
}
|
|
162
|
+
}, [path]);
|
|
163
|
+
const reset = useCallback(() => {
|
|
164
|
+
setLoading(false);
|
|
165
|
+
setError(null);
|
|
166
|
+
setData(null);
|
|
167
|
+
}, []);
|
|
168
|
+
return { mutate, loading, error, data, reset };
|
|
169
|
+
};
|
|
170
|
+
const fetch = async (options) => {
|
|
171
|
+
const endpoint2 = getEndpoint(path);
|
|
172
|
+
const result = await endpoint2({ input: options.input, select: options.select });
|
|
173
|
+
const mutationResult = result;
|
|
174
|
+
return mutationResult.data;
|
|
175
|
+
};
|
|
176
|
+
const endpoint = useMutationHook;
|
|
177
|
+
endpoint.fetch = fetch;
|
|
178
|
+
cachedHook = endpoint;
|
|
179
|
+
return cachedHook;
|
|
180
|
+
}
|
|
181
|
+
var hookCache = new Map;
|
|
182
|
+
function createClient(config) {
|
|
183
|
+
const baseClient = createBaseClient(config);
|
|
184
|
+
const _endpointTypes = new Map;
|
|
185
|
+
function createProxy(path) {
|
|
186
|
+
const cacheKey = path;
|
|
187
|
+
if (hookCache.has(cacheKey)) {
|
|
188
|
+
return hookCache.get(cacheKey);
|
|
189
|
+
}
|
|
190
|
+
const handler = {
|
|
191
|
+
get(_target, prop) {
|
|
192
|
+
if (typeof prop === "symbol")
|
|
193
|
+
return;
|
|
194
|
+
const key = prop;
|
|
195
|
+
if (key === "fetch") {
|
|
196
|
+
return async (options) => {
|
|
197
|
+
const parts = path.split(".");
|
|
198
|
+
let current = baseClient;
|
|
199
|
+
for (const part of parts) {
|
|
200
|
+
current = current[part];
|
|
201
|
+
}
|
|
202
|
+
const endpointFn = current;
|
|
203
|
+
const queryResult = endpointFn(options);
|
|
204
|
+
const result = await queryResult;
|
|
205
|
+
if (result && typeof result === "object" && "data" in result && Object.keys(result).length === 1) {
|
|
206
|
+
return result.data;
|
|
207
|
+
}
|
|
208
|
+
return result;
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
if (key === "then")
|
|
212
|
+
return;
|
|
213
|
+
if (key.startsWith("_"))
|
|
214
|
+
return;
|
|
215
|
+
const newPath = path ? `${path}.${key}` : key;
|
|
216
|
+
return createProxy(newPath);
|
|
217
|
+
},
|
|
218
|
+
apply(_target, _thisArg, args) {
|
|
219
|
+
const options = args[0];
|
|
220
|
+
const isQueryOptions = options && (("input" in options) || ("select" in options) || ("skip" in options));
|
|
221
|
+
const isMutationOptions = !options || !isQueryOptions && (Object.keys(options).length === 0 || ("onSuccess" in options) || ("onError" in options) || ("onSettled" in options));
|
|
222
|
+
const cacheKeyQuery = `${path}:query`;
|
|
223
|
+
const cacheKeyMutation = `${path}:mutation`;
|
|
224
|
+
if (isQueryOptions) {
|
|
225
|
+
if (!hookCache.has(cacheKeyQuery)) {
|
|
226
|
+
hookCache.set(cacheKeyQuery, createQueryHook(baseClient, path));
|
|
227
|
+
}
|
|
228
|
+
const hook2 = hookCache.get(cacheKeyQuery);
|
|
229
|
+
return hook2(options);
|
|
230
|
+
}
|
|
231
|
+
if (isMutationOptions) {
|
|
232
|
+
if (!hookCache.has(cacheKeyMutation)) {
|
|
233
|
+
hookCache.set(cacheKeyMutation, createMutationHook(baseClient, path));
|
|
234
|
+
}
|
|
235
|
+
const hook2 = hookCache.get(cacheKeyMutation);
|
|
236
|
+
return hook2(options);
|
|
237
|
+
}
|
|
238
|
+
if (!hookCache.has(cacheKeyQuery)) {
|
|
239
|
+
hookCache.set(cacheKeyQuery, createQueryHook(baseClient, path));
|
|
240
|
+
}
|
|
241
|
+
const hook = hookCache.get(cacheKeyQuery);
|
|
242
|
+
return hook(options);
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
const proxy = new Proxy(() => {}, handler);
|
|
246
|
+
return proxy;
|
|
247
|
+
}
|
|
248
|
+
return createProxy("");
|
|
249
|
+
}
|
|
1
250
|
// src/context.tsx
|
|
2
251
|
import { createContext, useContext } from "react";
|
|
3
|
-
import {
|
|
252
|
+
import { jsx } from "react/jsx-runtime";
|
|
4
253
|
var LENS_CONTEXT_KEY = Symbol.for("@sylphx/lens-react/context");
|
|
5
254
|
function getOrCreateContext() {
|
|
6
255
|
const globalObj = globalThis;
|
|
@@ -11,10 +260,10 @@ function getOrCreateContext() {
|
|
|
11
260
|
}
|
|
12
261
|
var LensContext = getOrCreateContext();
|
|
13
262
|
function LensProvider({ client, children }) {
|
|
14
|
-
return /* @__PURE__ */
|
|
263
|
+
return /* @__PURE__ */ jsx(LensContext.Provider, {
|
|
15
264
|
value: client,
|
|
16
265
|
children
|
|
17
|
-
}
|
|
266
|
+
});
|
|
18
267
|
}
|
|
19
268
|
function useLensClient() {
|
|
20
269
|
const client = useContext(LensContext);
|
|
@@ -25,14 +274,14 @@ function useLensClient() {
|
|
|
25
274
|
}
|
|
26
275
|
// src/hooks.ts
|
|
27
276
|
import {
|
|
28
|
-
useCallback,
|
|
29
|
-
useEffect,
|
|
30
|
-
useMemo,
|
|
31
|
-
useReducer,
|
|
32
|
-
useRef,
|
|
33
|
-
useState
|
|
277
|
+
useCallback as useCallback2,
|
|
278
|
+
useEffect as useEffect2,
|
|
279
|
+
useMemo as useMemo2,
|
|
280
|
+
useReducer as useReducer2,
|
|
281
|
+
useRef as useRef2,
|
|
282
|
+
useState as useState2
|
|
34
283
|
} from "react";
|
|
35
|
-
function
|
|
284
|
+
function queryReducer2(state, action) {
|
|
36
285
|
switch (action.type) {
|
|
37
286
|
case "RESET":
|
|
38
287
|
return { data: null, loading: false, error: null };
|
|
@@ -52,9 +301,9 @@ function useQuery(selector, paramsOrDeps, options) {
|
|
|
52
301
|
const client = useLensClient();
|
|
53
302
|
const isAccessorMode = Array.isArray(paramsOrDeps);
|
|
54
303
|
const paramsKey = !isAccessorMode ? JSON.stringify(paramsOrDeps) : null;
|
|
55
|
-
const selectorRef =
|
|
304
|
+
const selectorRef = useRef2(selector);
|
|
56
305
|
selectorRef.current = selector;
|
|
57
|
-
const query =
|
|
306
|
+
const query = useMemo2(() => {
|
|
58
307
|
if (options?.skip)
|
|
59
308
|
return null;
|
|
60
309
|
if (isAccessorMode) {
|
|
@@ -67,21 +316,21 @@ function useQuery(selector, paramsOrDeps, options) {
|
|
|
67
316
|
return null;
|
|
68
317
|
return route(paramsOrDeps);
|
|
69
318
|
}, isAccessorMode ? [client, options?.skip, ...paramsOrDeps] : [client, paramsKey, options?.skip]);
|
|
70
|
-
const selectRef =
|
|
319
|
+
const selectRef = useRef2(options?.select);
|
|
71
320
|
selectRef.current = options?.select;
|
|
72
321
|
const initialState = {
|
|
73
322
|
data: null,
|
|
74
323
|
loading: query != null && !options?.skip,
|
|
75
324
|
error: null
|
|
76
325
|
};
|
|
77
|
-
const [state, dispatch] =
|
|
78
|
-
const mountedRef =
|
|
79
|
-
const queryRef =
|
|
326
|
+
const [state, dispatch] = useReducer2(queryReducer2, initialState);
|
|
327
|
+
const mountedRef = useRef2(true);
|
|
328
|
+
const queryRef = useRef2(query);
|
|
80
329
|
queryRef.current = query;
|
|
81
|
-
const transform =
|
|
330
|
+
const transform = useCallback2((value) => {
|
|
82
331
|
return selectRef.current ? selectRef.current(value) : value;
|
|
83
332
|
}, []);
|
|
84
|
-
|
|
333
|
+
useEffect2(() => {
|
|
85
334
|
mountedRef.current = true;
|
|
86
335
|
if (query == null) {
|
|
87
336
|
dispatch({ type: "RESET" });
|
|
@@ -116,7 +365,7 @@ function useQuery(selector, paramsOrDeps, options) {
|
|
|
116
365
|
unsubscribe();
|
|
117
366
|
};
|
|
118
367
|
}, [query, transform]);
|
|
119
|
-
const refetch =
|
|
368
|
+
const refetch = useCallback2(() => {
|
|
120
369
|
const currentQuery = queryRef.current;
|
|
121
370
|
if (currentQuery == null)
|
|
122
371
|
return;
|
|
@@ -139,19 +388,19 @@ function useQuery(selector, paramsOrDeps, options) {
|
|
|
139
388
|
function useMutation(selector) {
|
|
140
389
|
const client = useLensClient();
|
|
141
390
|
const mutationFn = selector(client);
|
|
142
|
-
const [loading, setLoading] =
|
|
143
|
-
const [error, setError] =
|
|
144
|
-
const [data, setData] =
|
|
145
|
-
const mountedRef =
|
|
146
|
-
const mutationRef =
|
|
391
|
+
const [loading, setLoading] = useState2(false);
|
|
392
|
+
const [error, setError] = useState2(null);
|
|
393
|
+
const [data, setData] = useState2(null);
|
|
394
|
+
const mountedRef = useRef2(true);
|
|
395
|
+
const mutationRef = useRef2(mutationFn);
|
|
147
396
|
mutationRef.current = mutationFn;
|
|
148
|
-
|
|
397
|
+
useEffect2(() => {
|
|
149
398
|
mountedRef.current = true;
|
|
150
399
|
return () => {
|
|
151
400
|
mountedRef.current = false;
|
|
152
401
|
};
|
|
153
402
|
}, []);
|
|
154
|
-
const mutate =
|
|
403
|
+
const mutate = useCallback2(async (input) => {
|
|
155
404
|
setLoading(true);
|
|
156
405
|
setError(null);
|
|
157
406
|
try {
|
|
@@ -172,7 +421,7 @@ function useMutation(selector) {
|
|
|
172
421
|
}
|
|
173
422
|
}
|
|
174
423
|
}, []);
|
|
175
|
-
const reset =
|
|
424
|
+
const reset = useCallback2(() => {
|
|
176
425
|
setLoading(false);
|
|
177
426
|
setError(null);
|
|
178
427
|
setData(null);
|
|
@@ -181,24 +430,24 @@ function useMutation(selector) {
|
|
|
181
430
|
}
|
|
182
431
|
function useLazyQuery(selector, paramsOrDeps, options) {
|
|
183
432
|
const client = useLensClient();
|
|
184
|
-
const [data, setData] =
|
|
185
|
-
const [loading, setLoading] =
|
|
186
|
-
const [error, setError] =
|
|
187
|
-
const mountedRef =
|
|
433
|
+
const [data, setData] = useState2(null);
|
|
434
|
+
const [loading, setLoading] = useState2(false);
|
|
435
|
+
const [error, setError] = useState2(null);
|
|
436
|
+
const mountedRef = useRef2(true);
|
|
188
437
|
const isAccessorMode = Array.isArray(paramsOrDeps);
|
|
189
|
-
const selectorRef =
|
|
438
|
+
const selectorRef = useRef2(selector);
|
|
190
439
|
selectorRef.current = selector;
|
|
191
|
-
const paramsOrDepsRef =
|
|
440
|
+
const paramsOrDepsRef = useRef2(paramsOrDeps);
|
|
192
441
|
paramsOrDepsRef.current = paramsOrDeps;
|
|
193
|
-
const selectRef =
|
|
442
|
+
const selectRef = useRef2(options?.select);
|
|
194
443
|
selectRef.current = options?.select;
|
|
195
|
-
|
|
444
|
+
useEffect2(() => {
|
|
196
445
|
mountedRef.current = true;
|
|
197
446
|
return () => {
|
|
198
447
|
mountedRef.current = false;
|
|
199
448
|
};
|
|
200
449
|
}, []);
|
|
201
|
-
const execute =
|
|
450
|
+
const execute = useCallback2(async () => {
|
|
202
451
|
let query;
|
|
203
452
|
if (isAccessorMode) {
|
|
204
453
|
const querySelector = selectorRef.current;
|
|
@@ -236,7 +485,7 @@ function useLazyQuery(selector, paramsOrDeps, options) {
|
|
|
236
485
|
}
|
|
237
486
|
}
|
|
238
487
|
}, [client, isAccessorMode]);
|
|
239
|
-
const reset =
|
|
488
|
+
const reset = useCallback2(() => {
|
|
240
489
|
setLoading(false);
|
|
241
490
|
setError(null);
|
|
242
491
|
setData(null);
|
|
@@ -248,5 +497,6 @@ export {
|
|
|
248
497
|
useMutation,
|
|
249
498
|
useLensClient,
|
|
250
499
|
useLazyQuery,
|
|
500
|
+
createClient,
|
|
251
501
|
LensProvider
|
|
252
502
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sylphx/lens-react",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "React bindings for Lens API framework",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
}
|
|
13
13
|
},
|
|
14
14
|
"scripts": {
|
|
15
|
-
"build": "bunup",
|
|
15
|
+
"build": "NODE_ENV=production bunup",
|
|
16
16
|
"typecheck": "tsc --noEmit",
|
|
17
17
|
"test": "bun test",
|
|
18
18
|
"prepack": "[ -d dist ] || bun run build"
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"author": "SylphxAI",
|
|
31
31
|
"license": "MIT",
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@sylphx/lens-client": "^2.0
|
|
33
|
+
"@sylphx/lens-client": "^2.1.0"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
36
|
"react": ">=18.0.0"
|
package/src/create.tsx
ADDED
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sylphx/lens-react - Create Client
|
|
3
|
+
*
|
|
4
|
+
* Creates a typed Lens client with React hooks.
|
|
5
|
+
* Each endpoint can be called directly as a hook or via .fetch() for promises.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* // lib/client.ts
|
|
10
|
+
* import { createClient } from '@sylphx/lens-react';
|
|
11
|
+
* import { httpTransport } from '@sylphx/lens-client';
|
|
12
|
+
* import type { AppRouter } from '@/server/router';
|
|
13
|
+
*
|
|
14
|
+
* export const client = createClient<AppRouter>({
|
|
15
|
+
* transport: httpTransport({ url: '/api/lens' }),
|
|
16
|
+
* });
|
|
17
|
+
*
|
|
18
|
+
* // In component
|
|
19
|
+
* function UserProfile({ id }: { id: string }) {
|
|
20
|
+
* const { data, loading } = client.user.get({ input: { id } });
|
|
21
|
+
* return <div>{data?.name}</div>;
|
|
22
|
+
* }
|
|
23
|
+
*
|
|
24
|
+
* // In SSR
|
|
25
|
+
* const user = await client.user.get.fetch({ input: { id } });
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
createClient as createBaseClient,
|
|
31
|
+
type LensClientConfig,
|
|
32
|
+
type QueryResult,
|
|
33
|
+
type SelectionObject,
|
|
34
|
+
type TypedClientConfig,
|
|
35
|
+
} from "@sylphx/lens-client";
|
|
36
|
+
import type { MutationDef, QueryDef, RouterDef, RouterRoutes } from "@sylphx/lens-core";
|
|
37
|
+
import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
|
|
38
|
+
|
|
39
|
+
// =============================================================================
|
|
40
|
+
// Types
|
|
41
|
+
// =============================================================================
|
|
42
|
+
|
|
43
|
+
/** Query hook options */
|
|
44
|
+
export interface QueryHookOptions<TInput> {
|
|
45
|
+
/** Query input parameters */
|
|
46
|
+
input?: TInput;
|
|
47
|
+
/** Field selection */
|
|
48
|
+
select?: SelectionObject;
|
|
49
|
+
/** Skip query execution */
|
|
50
|
+
skip?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Query hook result */
|
|
54
|
+
export interface QueryHookResult<T> {
|
|
55
|
+
/** Query data (null if loading or error) */
|
|
56
|
+
data: T | null;
|
|
57
|
+
/** Loading state */
|
|
58
|
+
loading: boolean;
|
|
59
|
+
/** Error state */
|
|
60
|
+
error: Error | null;
|
|
61
|
+
/** Refetch the query */
|
|
62
|
+
refetch: () => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Mutation hook options */
|
|
66
|
+
export interface MutationHookOptions<TOutput> {
|
|
67
|
+
/** Called on successful mutation */
|
|
68
|
+
onSuccess?: (data: TOutput) => void;
|
|
69
|
+
/** Called on mutation error */
|
|
70
|
+
onError?: (error: Error) => void;
|
|
71
|
+
/** Called when mutation settles (success or error) */
|
|
72
|
+
onSettled?: () => void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Mutation hook result */
|
|
76
|
+
export interface MutationHookResult<TInput, TOutput> {
|
|
77
|
+
/** Execute the mutation */
|
|
78
|
+
mutate: (options: { input: TInput; select?: SelectionObject }) => Promise<TOutput>;
|
|
79
|
+
/** Mutation is in progress */
|
|
80
|
+
loading: boolean;
|
|
81
|
+
/** Mutation error */
|
|
82
|
+
error: Error | null;
|
|
83
|
+
/** Last mutation result */
|
|
84
|
+
data: TOutput | null;
|
|
85
|
+
/** Reset mutation state */
|
|
86
|
+
reset: () => void;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Query endpoint type */
|
|
90
|
+
export interface QueryEndpoint<TInput, TOutput> {
|
|
91
|
+
/** Hook call (in component) */
|
|
92
|
+
<_TSelect extends SelectionObject = Record<string, never>>(
|
|
93
|
+
options: TInput extends void ? QueryHookOptions<void> | void : QueryHookOptions<TInput>,
|
|
94
|
+
): QueryHookResult<TOutput>;
|
|
95
|
+
|
|
96
|
+
/** Promise call (SSR) */
|
|
97
|
+
fetch: <TSelect extends SelectionObject = Record<string, never>>(
|
|
98
|
+
options: TInput extends void
|
|
99
|
+
? { input?: void; select?: TSelect } | void
|
|
100
|
+
: { input: TInput; select?: TSelect },
|
|
101
|
+
) => Promise<TOutput>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Mutation endpoint type */
|
|
105
|
+
export interface MutationEndpoint<TInput, TOutput> {
|
|
106
|
+
/** Hook call (in component) */
|
|
107
|
+
(options?: MutationHookOptions<TOutput>): MutationHookResult<TInput, TOutput>;
|
|
108
|
+
|
|
109
|
+
/** Promise call (SSR) */
|
|
110
|
+
fetch: <TSelect extends SelectionObject = Record<string, never>>(options: {
|
|
111
|
+
input: TInput;
|
|
112
|
+
select?: TSelect;
|
|
113
|
+
}) => Promise<TOutput>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Infer client type from router routes */
|
|
117
|
+
type InferTypedClient<TRoutes extends RouterRoutes> = {
|
|
118
|
+
[K in keyof TRoutes]: TRoutes[K] extends RouterDef<infer TNestedRoutes>
|
|
119
|
+
? InferTypedClient<TNestedRoutes>
|
|
120
|
+
: TRoutes[K] extends QueryDef<infer TInput, infer TOutput>
|
|
121
|
+
? QueryEndpoint<TInput, TOutput>
|
|
122
|
+
: TRoutes[K] extends MutationDef<infer TInput, infer TOutput>
|
|
123
|
+
? MutationEndpoint<TInput, TOutput>
|
|
124
|
+
: never;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** Typed client from router */
|
|
128
|
+
export type TypedClient<TRouter extends RouterDef> =
|
|
129
|
+
TRouter extends RouterDef<infer TRoutes> ? InferTypedClient<TRoutes> : never;
|
|
130
|
+
|
|
131
|
+
// =============================================================================
|
|
132
|
+
// Query State Reducer
|
|
133
|
+
// =============================================================================
|
|
134
|
+
|
|
135
|
+
interface QueryState<T> {
|
|
136
|
+
data: T | null;
|
|
137
|
+
loading: boolean;
|
|
138
|
+
error: Error | null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
type QueryAction<T> =
|
|
142
|
+
| { type: "RESET" }
|
|
143
|
+
| { type: "START" }
|
|
144
|
+
| { type: "SUCCESS"; data: T }
|
|
145
|
+
| { type: "ERROR"; error: Error }
|
|
146
|
+
| { type: "LOADING_DONE" };
|
|
147
|
+
|
|
148
|
+
function queryReducer<T>(state: QueryState<T>, action: QueryAction<T>): QueryState<T> {
|
|
149
|
+
switch (action.type) {
|
|
150
|
+
case "RESET":
|
|
151
|
+
return { data: null, loading: false, error: null };
|
|
152
|
+
case "START":
|
|
153
|
+
return { ...state, loading: true, error: null };
|
|
154
|
+
case "SUCCESS":
|
|
155
|
+
return { data: action.data, loading: false, error: null };
|
|
156
|
+
case "ERROR":
|
|
157
|
+
return { ...state, loading: false, error: action.error };
|
|
158
|
+
case "LOADING_DONE":
|
|
159
|
+
return { ...state, loading: false };
|
|
160
|
+
default:
|
|
161
|
+
return state;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// =============================================================================
|
|
166
|
+
// Hook Factories
|
|
167
|
+
// =============================================================================
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Create a query hook for a specific endpoint
|
|
171
|
+
*/
|
|
172
|
+
function createQueryHook<TInput, TOutput>(
|
|
173
|
+
baseClient: unknown,
|
|
174
|
+
path: string,
|
|
175
|
+
): QueryEndpoint<TInput, TOutput> {
|
|
176
|
+
// Cache for stable hook reference
|
|
177
|
+
let cachedHook: QueryEndpoint<TInput, TOutput> | null = null;
|
|
178
|
+
|
|
179
|
+
const getEndpoint = (p: string) => {
|
|
180
|
+
const parts = p.split(".");
|
|
181
|
+
let current: unknown = baseClient;
|
|
182
|
+
for (const part of parts) {
|
|
183
|
+
current = (current as Record<string, unknown>)[part];
|
|
184
|
+
}
|
|
185
|
+
return current as (options: unknown) => QueryResult<TOutput>;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const useQueryHook = (options?: QueryHookOptions<TInput>): QueryHookResult<TOutput> => {
|
|
189
|
+
const _optionsKey = JSON.stringify(options ?? {});
|
|
190
|
+
|
|
191
|
+
// Get query result from base client
|
|
192
|
+
const query = useMemo(() => {
|
|
193
|
+
if (options?.skip) return null;
|
|
194
|
+
const endpoint = getEndpoint(path);
|
|
195
|
+
return endpoint({ input: options?.input, select: options?.select });
|
|
196
|
+
}, [options?.input, options?.select, options?.skip, path]);
|
|
197
|
+
|
|
198
|
+
// State management
|
|
199
|
+
const initialState: QueryState<TOutput> = {
|
|
200
|
+
data: null,
|
|
201
|
+
loading: query != null && !options?.skip,
|
|
202
|
+
error: null,
|
|
203
|
+
};
|
|
204
|
+
const [state, dispatch] = useReducer(queryReducer<TOutput>, initialState);
|
|
205
|
+
|
|
206
|
+
// Track mounted state
|
|
207
|
+
const mountedRef = useRef(true);
|
|
208
|
+
const queryRef = useRef(query);
|
|
209
|
+
queryRef.current = query;
|
|
210
|
+
|
|
211
|
+
// Subscribe to query
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
mountedRef.current = true;
|
|
214
|
+
|
|
215
|
+
if (query == null) {
|
|
216
|
+
dispatch({ type: "RESET" });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
dispatch({ type: "START" });
|
|
221
|
+
|
|
222
|
+
let hasReceivedData = false;
|
|
223
|
+
|
|
224
|
+
const unsubscribe = query.subscribe((value) => {
|
|
225
|
+
if (mountedRef.current) {
|
|
226
|
+
hasReceivedData = true;
|
|
227
|
+
dispatch({ type: "SUCCESS", data: value });
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
query.then(
|
|
232
|
+
(value) => {
|
|
233
|
+
if (mountedRef.current) {
|
|
234
|
+
if (!hasReceivedData) {
|
|
235
|
+
dispatch({ type: "SUCCESS", data: value });
|
|
236
|
+
} else {
|
|
237
|
+
dispatch({ type: "LOADING_DONE" });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
(err) => {
|
|
242
|
+
if (mountedRef.current) {
|
|
243
|
+
dispatch({
|
|
244
|
+
type: "ERROR",
|
|
245
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
return () => {
|
|
252
|
+
mountedRef.current = false;
|
|
253
|
+
unsubscribe();
|
|
254
|
+
};
|
|
255
|
+
}, [query]);
|
|
256
|
+
|
|
257
|
+
// Refetch function
|
|
258
|
+
const refetch = useCallback(() => {
|
|
259
|
+
const currentQuery = queryRef.current;
|
|
260
|
+
if (currentQuery == null) return;
|
|
261
|
+
|
|
262
|
+
dispatch({ type: "START" });
|
|
263
|
+
|
|
264
|
+
currentQuery.then(
|
|
265
|
+
(value) => {
|
|
266
|
+
if (mountedRef.current) {
|
|
267
|
+
dispatch({ type: "SUCCESS", data: value });
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
(err) => {
|
|
271
|
+
if (mountedRef.current) {
|
|
272
|
+
dispatch({
|
|
273
|
+
type: "ERROR",
|
|
274
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
},
|
|
278
|
+
);
|
|
279
|
+
}, []);
|
|
280
|
+
|
|
281
|
+
return { data: state.data, loading: state.loading, error: state.error, refetch };
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
// Fetch method for promises (SSR)
|
|
285
|
+
const fetch = async (options?: {
|
|
286
|
+
input?: TInput;
|
|
287
|
+
select?: SelectionObject;
|
|
288
|
+
}): Promise<TOutput> => {
|
|
289
|
+
const endpoint = getEndpoint(path);
|
|
290
|
+
const queryResult = endpoint({ input: options?.input, select: options?.select });
|
|
291
|
+
return queryResult.then((data) => data);
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
// Create the endpoint object with hook + fetch
|
|
295
|
+
const endpoint = useQueryHook as QueryEndpoint<TInput, TOutput>;
|
|
296
|
+
endpoint.fetch = fetch;
|
|
297
|
+
|
|
298
|
+
cachedHook = endpoint;
|
|
299
|
+
return cachedHook;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Create a mutation hook for a specific endpoint
|
|
304
|
+
*/
|
|
305
|
+
function createMutationHook<TInput, TOutput>(
|
|
306
|
+
baseClient: unknown,
|
|
307
|
+
path: string,
|
|
308
|
+
): MutationEndpoint<TInput, TOutput> {
|
|
309
|
+
let cachedHook: MutationEndpoint<TInput, TOutput> | null = null;
|
|
310
|
+
|
|
311
|
+
const getEndpoint = (p: string) => {
|
|
312
|
+
const parts = p.split(".");
|
|
313
|
+
let current: unknown = baseClient;
|
|
314
|
+
for (const part of parts) {
|
|
315
|
+
current = (current as Record<string, unknown>)[part];
|
|
316
|
+
}
|
|
317
|
+
return current as (options: unknown) => QueryResult<{ data: TOutput }>;
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const useMutationHook = (
|
|
321
|
+
hookOptions?: MutationHookOptions<TOutput>,
|
|
322
|
+
): MutationHookResult<TInput, TOutput> => {
|
|
323
|
+
const [loading, setLoading] = React.useState(false);
|
|
324
|
+
const [error, setError] = React.useState<Error | null>(null);
|
|
325
|
+
const [data, setData] = React.useState<TOutput | null>(null);
|
|
326
|
+
|
|
327
|
+
const mountedRef = useRef(true);
|
|
328
|
+
const hookOptionsRef = useRef(hookOptions);
|
|
329
|
+
hookOptionsRef.current = hookOptions;
|
|
330
|
+
|
|
331
|
+
useEffect(() => {
|
|
332
|
+
mountedRef.current = true;
|
|
333
|
+
return () => {
|
|
334
|
+
mountedRef.current = false;
|
|
335
|
+
};
|
|
336
|
+
}, []);
|
|
337
|
+
|
|
338
|
+
const mutate = useCallback(
|
|
339
|
+
async (options: { input: TInput; select?: SelectionObject }): Promise<TOutput> => {
|
|
340
|
+
setLoading(true);
|
|
341
|
+
setError(null);
|
|
342
|
+
|
|
343
|
+
try {
|
|
344
|
+
const endpoint = getEndpoint(path);
|
|
345
|
+
const result = await endpoint({ input: options.input, select: options.select });
|
|
346
|
+
const mutationResult = result as unknown as { data: TOutput };
|
|
347
|
+
|
|
348
|
+
if (mountedRef.current) {
|
|
349
|
+
setData(mutationResult.data);
|
|
350
|
+
setLoading(false);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
hookOptionsRef.current?.onSuccess?.(mutationResult.data);
|
|
354
|
+
hookOptionsRef.current?.onSettled?.();
|
|
355
|
+
|
|
356
|
+
return mutationResult.data;
|
|
357
|
+
} catch (err) {
|
|
358
|
+
const mutationError = err instanceof Error ? err : new Error(String(err));
|
|
359
|
+
|
|
360
|
+
if (mountedRef.current) {
|
|
361
|
+
setError(mutationError);
|
|
362
|
+
setLoading(false);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
hookOptionsRef.current?.onError?.(mutationError);
|
|
366
|
+
hookOptionsRef.current?.onSettled?.();
|
|
367
|
+
|
|
368
|
+
throw mutationError;
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
[path],
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
const reset = useCallback(() => {
|
|
375
|
+
setLoading(false);
|
|
376
|
+
setError(null);
|
|
377
|
+
setData(null);
|
|
378
|
+
}, []);
|
|
379
|
+
|
|
380
|
+
return { mutate, loading, error, data, reset };
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
// Fetch method for promises (SSR)
|
|
384
|
+
const fetch = async (options: { input: TInput; select?: SelectionObject }): Promise<TOutput> => {
|
|
385
|
+
const endpoint = getEndpoint(path);
|
|
386
|
+
const result = await endpoint({ input: options.input, select: options.select });
|
|
387
|
+
const mutationResult = result as unknown as { data: TOutput };
|
|
388
|
+
return mutationResult.data;
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
const endpoint = useMutationHook as MutationEndpoint<TInput, TOutput>;
|
|
392
|
+
endpoint.fetch = fetch;
|
|
393
|
+
|
|
394
|
+
cachedHook = endpoint;
|
|
395
|
+
return cachedHook;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// =============================================================================
|
|
399
|
+
// React import for useState (needed in mutation hook)
|
|
400
|
+
// =============================================================================
|
|
401
|
+
|
|
402
|
+
import * as React from "react";
|
|
403
|
+
|
|
404
|
+
// =============================================================================
|
|
405
|
+
// Create Client
|
|
406
|
+
// =============================================================================
|
|
407
|
+
|
|
408
|
+
// Cache for hook functions to ensure stable references
|
|
409
|
+
const hookCache = new Map<string, unknown>();
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Create a Lens client with React hooks.
|
|
413
|
+
*
|
|
414
|
+
* Each endpoint can be called:
|
|
415
|
+
* - Directly as a hook: `client.user.get({ input: { id } })`
|
|
416
|
+
* - Via .fetch() for promises: `await client.user.get.fetch({ input: { id } })`
|
|
417
|
+
*
|
|
418
|
+
* @example
|
|
419
|
+
* ```tsx
|
|
420
|
+
* // lib/client.ts
|
|
421
|
+
* import { createClient } from '@sylphx/lens-react';
|
|
422
|
+
* import { httpTransport } from '@sylphx/lens-client';
|
|
423
|
+
* import type { AppRouter } from '@/server/router';
|
|
424
|
+
*
|
|
425
|
+
* export const client = createClient<AppRouter>({
|
|
426
|
+
* transport: httpTransport({ url: '/api/lens' }),
|
|
427
|
+
* });
|
|
428
|
+
*
|
|
429
|
+
* // Component usage
|
|
430
|
+
* function UserProfile({ id }: { id: string }) {
|
|
431
|
+
* // Query hook - auto-subscribes
|
|
432
|
+
* const { data, loading, error } = client.user.get({
|
|
433
|
+
* input: { id },
|
|
434
|
+
* select: { name: true },
|
|
435
|
+
* });
|
|
436
|
+
*
|
|
437
|
+
* // Mutation hook - returns mutate function
|
|
438
|
+
* const { mutate, loading: saving } = client.user.update({
|
|
439
|
+
* onSuccess: () => toast('Updated!'),
|
|
440
|
+
* });
|
|
441
|
+
*
|
|
442
|
+
* if (loading) return <Spinner />;
|
|
443
|
+
* return (
|
|
444
|
+
* <div>
|
|
445
|
+
* <h1>{data?.name}</h1>
|
|
446
|
+
* <button onClick={() => mutate({ input: { id, name: 'New' } })}>
|
|
447
|
+
* Update
|
|
448
|
+
* </button>
|
|
449
|
+
* </div>
|
|
450
|
+
* );
|
|
451
|
+
* }
|
|
452
|
+
*
|
|
453
|
+
* // SSR usage
|
|
454
|
+
* async function UserPage({ id }: { id: string }) {
|
|
455
|
+
* const user = await client.user.get.fetch({ input: { id } });
|
|
456
|
+
* return <div>{user.name}</div>;
|
|
457
|
+
* }
|
|
458
|
+
* ```
|
|
459
|
+
*/
|
|
460
|
+
export function createClient<TRouter extends RouterDef>(
|
|
461
|
+
config: LensClientConfig | TypedClientConfig<{ router: TRouter }>,
|
|
462
|
+
): TypedClient<TRouter> {
|
|
463
|
+
// Create base client for transport
|
|
464
|
+
const baseClient = createBaseClient(config as LensClientConfig);
|
|
465
|
+
|
|
466
|
+
// Track endpoint types (query vs mutation) - determined at runtime via metadata
|
|
467
|
+
// For now, we'll detect based on the operation result
|
|
468
|
+
const _endpointTypes = new Map<string, "query" | "mutation">();
|
|
469
|
+
|
|
470
|
+
function createProxy(path: string): unknown {
|
|
471
|
+
const cacheKey = path;
|
|
472
|
+
|
|
473
|
+
// Return cached hook if available
|
|
474
|
+
if (hookCache.has(cacheKey)) {
|
|
475
|
+
return hookCache.get(cacheKey);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const handler: ProxyHandler<(...args: unknown[]) => unknown> = {
|
|
479
|
+
get(_target, prop) {
|
|
480
|
+
if (typeof prop === "symbol") return undefined;
|
|
481
|
+
const key = prop as string;
|
|
482
|
+
|
|
483
|
+
// Handle .fetch() method - returns a promise
|
|
484
|
+
if (key === "fetch") {
|
|
485
|
+
return async (options: unknown) => {
|
|
486
|
+
// Navigate to the endpoint in base client
|
|
487
|
+
const parts = path.split(".");
|
|
488
|
+
let current: unknown = baseClient;
|
|
489
|
+
for (const part of parts) {
|
|
490
|
+
current = (current as Record<string, unknown>)[part];
|
|
491
|
+
}
|
|
492
|
+
const endpointFn = current as (opts: unknown) => QueryResult<unknown>;
|
|
493
|
+
const queryResult = endpointFn(options);
|
|
494
|
+
|
|
495
|
+
// Await the result
|
|
496
|
+
const result = await queryResult;
|
|
497
|
+
|
|
498
|
+
// For mutations, the result is { data: ... }
|
|
499
|
+
// For queries, the result is the data directly
|
|
500
|
+
if (
|
|
501
|
+
result &&
|
|
502
|
+
typeof result === "object" &&
|
|
503
|
+
"data" in result &&
|
|
504
|
+
Object.keys(result).length === 1
|
|
505
|
+
) {
|
|
506
|
+
return (result as { data: unknown }).data;
|
|
507
|
+
}
|
|
508
|
+
return result;
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (key === "then") return undefined;
|
|
513
|
+
if (key.startsWith("_")) return undefined;
|
|
514
|
+
|
|
515
|
+
const newPath = path ? `${path}.${key}` : key;
|
|
516
|
+
return createProxy(newPath);
|
|
517
|
+
},
|
|
518
|
+
|
|
519
|
+
apply(_target, _thisArg, args) {
|
|
520
|
+
// This is called when the endpoint is invoked as a function
|
|
521
|
+
// Detect query vs mutation based on options shape:
|
|
522
|
+
// - Query: has `input` or `select` or `skip` (QueryHookOptions)
|
|
523
|
+
// - Mutation: has `onSuccess`, `onError`, `onSettled` or no options (MutationHookOptions)
|
|
524
|
+
|
|
525
|
+
const options = args[0] as Record<string, unknown> | undefined;
|
|
526
|
+
|
|
527
|
+
// Detect based on option keys
|
|
528
|
+
const isQueryOptions =
|
|
529
|
+
options && ("input" in options || "select" in options || "skip" in options);
|
|
530
|
+
|
|
531
|
+
const isMutationOptions =
|
|
532
|
+
!options ||
|
|
533
|
+
(!isQueryOptions &&
|
|
534
|
+
(Object.keys(options).length === 0 ||
|
|
535
|
+
"onSuccess" in options ||
|
|
536
|
+
"onError" in options ||
|
|
537
|
+
"onSettled" in options));
|
|
538
|
+
|
|
539
|
+
// Check cache - but we need to know the type first
|
|
540
|
+
const cacheKeyQuery = `${path}:query`;
|
|
541
|
+
const cacheKeyMutation = `${path}:mutation`;
|
|
542
|
+
|
|
543
|
+
if (isQueryOptions) {
|
|
544
|
+
if (!hookCache.has(cacheKeyQuery)) {
|
|
545
|
+
hookCache.set(cacheKeyQuery, createQueryHook(baseClient, path));
|
|
546
|
+
}
|
|
547
|
+
const hook = hookCache.get(cacheKeyQuery) as (opts: unknown) => unknown;
|
|
548
|
+
return hook(options);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
if (isMutationOptions) {
|
|
552
|
+
if (!hookCache.has(cacheKeyMutation)) {
|
|
553
|
+
hookCache.set(cacheKeyMutation, createMutationHook(baseClient, path));
|
|
554
|
+
}
|
|
555
|
+
const hook = hookCache.get(cacheKeyMutation) as (opts: unknown) => unknown;
|
|
556
|
+
return hook(options);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Fallback to query
|
|
560
|
+
if (!hookCache.has(cacheKeyQuery)) {
|
|
561
|
+
hookCache.set(cacheKeyQuery, createQueryHook(baseClient, path));
|
|
562
|
+
}
|
|
563
|
+
const hook = hookCache.get(cacheKeyQuery) as (opts: unknown) => unknown;
|
|
564
|
+
return hook(options);
|
|
565
|
+
},
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
const proxy = new Proxy((() => {}) as (...args: unknown[]) => unknown, handler);
|
|
569
|
+
return proxy;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return createProxy("") as TypedClient<TRouter>;
|
|
573
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -3,18 +3,50 @@
|
|
|
3
3
|
*
|
|
4
4
|
* React bindings for Lens API framework.
|
|
5
5
|
* Hooks and context provider for reactive data access.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* // lib/client.ts
|
|
10
|
+
* import { createClient } from '@sylphx/lens-react';
|
|
11
|
+
* import { httpTransport } from '@sylphx/lens-client';
|
|
12
|
+
* import type { AppRouter } from '@/server/router';
|
|
13
|
+
*
|
|
14
|
+
* export const client = createClient<AppRouter>({
|
|
15
|
+
* transport: httpTransport({ url: '/api/lens' }),
|
|
16
|
+
* });
|
|
17
|
+
*
|
|
18
|
+
* // Component usage
|
|
19
|
+
* function UserProfile({ id }: { id: string }) {
|
|
20
|
+
* const { data, loading } = client.user.get({ input: { id } });
|
|
21
|
+
* return <div>{data?.name}</div>;
|
|
22
|
+
* }
|
|
23
|
+
*
|
|
24
|
+
* // SSR usage
|
|
25
|
+
* const user = await client.user.get.fetch({ input: { id } });
|
|
26
|
+
* ```
|
|
6
27
|
*/
|
|
7
28
|
|
|
8
29
|
// =============================================================================
|
|
9
|
-
//
|
|
30
|
+
// New API (v4) - Recommended
|
|
10
31
|
// =============================================================================
|
|
11
32
|
|
|
12
|
-
export {
|
|
33
|
+
export {
|
|
34
|
+
createClient,
|
|
35
|
+
type MutationEndpoint,
|
|
36
|
+
type MutationHookOptions,
|
|
37
|
+
type MutationHookResult,
|
|
38
|
+
type QueryEndpoint,
|
|
39
|
+
type QueryHookOptions,
|
|
40
|
+
type QueryHookResult,
|
|
41
|
+
type TypedClient,
|
|
42
|
+
} from "./create.js";
|
|
13
43
|
|
|
14
44
|
// =============================================================================
|
|
15
|
-
//
|
|
45
|
+
// Legacy API (v3) - Deprecated, will be removed in v3.0
|
|
16
46
|
// =============================================================================
|
|
17
47
|
|
|
48
|
+
export { LensProvider, type LensProviderProps, useLensClient } from "./context.js";
|
|
49
|
+
|
|
18
50
|
export {
|
|
19
51
|
// Types
|
|
20
52
|
type MutationSelector,
|