@sylphx/lens-react 2.1.4 → 2.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +63 -28
- package/package.json +1 -1
- package/src/context.tsx +28 -3
- package/src/hooks.ts +76 -27
package/dist/index.js
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
// src/context.tsx
|
|
2
2
|
import { createContext, useContext } from "react";
|
|
3
3
|
import { jsxDEV } from "react/jsx-dev-runtime";
|
|
4
|
-
var
|
|
4
|
+
var LENS_CONTEXT_KEY = Symbol.for("@sylphx/lens-react/context");
|
|
5
|
+
function getOrCreateContext() {
|
|
6
|
+
const globalObj = globalThis;
|
|
7
|
+
if (!globalObj[LENS_CONTEXT_KEY]) {
|
|
8
|
+
globalObj[LENS_CONTEXT_KEY] = createContext(null);
|
|
9
|
+
}
|
|
10
|
+
return globalObj[LENS_CONTEXT_KEY];
|
|
11
|
+
}
|
|
12
|
+
var LensContext = getOrCreateContext();
|
|
5
13
|
function LensProvider({ client, children }) {
|
|
6
14
|
return /* @__PURE__ */ jsxDEV(LensContext.Provider, {
|
|
7
15
|
value: client,
|
|
@@ -16,29 +24,57 @@ function useLensClient() {
|
|
|
16
24
|
return client;
|
|
17
25
|
}
|
|
18
26
|
// src/hooks.ts
|
|
19
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
useCallback,
|
|
29
|
+
useEffect,
|
|
30
|
+
useMemo,
|
|
31
|
+
useReducer,
|
|
32
|
+
useRef,
|
|
33
|
+
useState
|
|
34
|
+
} from "react";
|
|
35
|
+
function queryReducer(state, action) {
|
|
36
|
+
switch (action.type) {
|
|
37
|
+
case "RESET":
|
|
38
|
+
return { data: null, loading: false, error: null };
|
|
39
|
+
case "START":
|
|
40
|
+
return { ...state, loading: true, error: null };
|
|
41
|
+
case "SUCCESS":
|
|
42
|
+
return { data: action.data, loading: false, error: null };
|
|
43
|
+
case "ERROR":
|
|
44
|
+
return { ...state, loading: false, error: action.error };
|
|
45
|
+
case "LOADING_DONE":
|
|
46
|
+
return { ...state, loading: false };
|
|
47
|
+
default:
|
|
48
|
+
return state;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
20
51
|
function useQuery(selector, paramsOrDeps, options) {
|
|
21
52
|
const client = useLensClient();
|
|
22
53
|
const isAccessorMode = Array.isArray(paramsOrDeps);
|
|
23
54
|
const paramsKey = !isAccessorMode ? JSON.stringify(paramsOrDeps) : null;
|
|
55
|
+
const selectorRef = useRef(selector);
|
|
56
|
+
selectorRef.current = selector;
|
|
24
57
|
const query = useMemo(() => {
|
|
25
58
|
if (options?.skip)
|
|
26
59
|
return null;
|
|
27
60
|
if (isAccessorMode) {
|
|
28
|
-
const querySelector =
|
|
61
|
+
const querySelector = selectorRef.current;
|
|
29
62
|
return querySelector(client);
|
|
30
63
|
}
|
|
31
|
-
const routeSelector =
|
|
64
|
+
const routeSelector = selectorRef.current;
|
|
32
65
|
const route = routeSelector(client);
|
|
33
66
|
if (!route)
|
|
34
67
|
return null;
|
|
35
68
|
return route(paramsOrDeps);
|
|
36
|
-
}, isAccessorMode ? [client, options?.skip, ...paramsOrDeps] : [client,
|
|
69
|
+
}, isAccessorMode ? [client, options?.skip, ...paramsOrDeps] : [client, paramsKey, options?.skip]);
|
|
37
70
|
const selectRef = useRef(options?.select);
|
|
38
71
|
selectRef.current = options?.select;
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
72
|
+
const initialState = {
|
|
73
|
+
data: null,
|
|
74
|
+
loading: query != null && !options?.skip,
|
|
75
|
+
error: null
|
|
76
|
+
};
|
|
77
|
+
const [state, dispatch] = useReducer(queryReducer, initialState);
|
|
42
78
|
const mountedRef = useRef(true);
|
|
43
79
|
const queryRef = useRef(query);
|
|
44
80
|
queryRef.current = query;
|
|
@@ -48,32 +84,31 @@ function useQuery(selector, paramsOrDeps, options) {
|
|
|
48
84
|
useEffect(() => {
|
|
49
85
|
mountedRef.current = true;
|
|
50
86
|
if (query == null) {
|
|
51
|
-
|
|
52
|
-
setLoading(false);
|
|
53
|
-
setError(null);
|
|
87
|
+
dispatch({ type: "RESET" });
|
|
54
88
|
return;
|
|
55
89
|
}
|
|
56
|
-
|
|
57
|
-
setError(null);
|
|
90
|
+
dispatch({ type: "START" });
|
|
58
91
|
let hasReceivedData = false;
|
|
59
92
|
const unsubscribe = query.subscribe((value) => {
|
|
60
93
|
if (mountedRef.current) {
|
|
61
94
|
hasReceivedData = true;
|
|
62
|
-
|
|
63
|
-
setLoading(false);
|
|
95
|
+
dispatch({ type: "SUCCESS", data: transform(value) });
|
|
64
96
|
}
|
|
65
97
|
});
|
|
66
98
|
query.then((value) => {
|
|
67
|
-
if (mountedRef.current && !hasReceivedData) {
|
|
68
|
-
setData(transform(value));
|
|
69
|
-
}
|
|
70
99
|
if (mountedRef.current) {
|
|
71
|
-
|
|
100
|
+
if (!hasReceivedData) {
|
|
101
|
+
dispatch({ type: "SUCCESS", data: transform(value) });
|
|
102
|
+
} else {
|
|
103
|
+
dispatch({ type: "LOADING_DONE" });
|
|
104
|
+
}
|
|
72
105
|
}
|
|
73
106
|
}, (err) => {
|
|
74
107
|
if (mountedRef.current) {
|
|
75
|
-
|
|
76
|
-
|
|
108
|
+
dispatch({
|
|
109
|
+
type: "ERROR",
|
|
110
|
+
error: err instanceof Error ? err : new Error(String(err))
|
|
111
|
+
});
|
|
77
112
|
}
|
|
78
113
|
});
|
|
79
114
|
return () => {
|
|
@@ -85,21 +120,21 @@ function useQuery(selector, paramsOrDeps, options) {
|
|
|
85
120
|
const currentQuery = queryRef.current;
|
|
86
121
|
if (currentQuery == null)
|
|
87
122
|
return;
|
|
88
|
-
|
|
89
|
-
setError(null);
|
|
123
|
+
dispatch({ type: "START" });
|
|
90
124
|
currentQuery.then((value) => {
|
|
91
125
|
if (mountedRef.current) {
|
|
92
|
-
|
|
93
|
-
setLoading(false);
|
|
126
|
+
dispatch({ type: "SUCCESS", data: transform(value) });
|
|
94
127
|
}
|
|
95
128
|
}, (err) => {
|
|
96
129
|
if (mountedRef.current) {
|
|
97
|
-
|
|
98
|
-
|
|
130
|
+
dispatch({
|
|
131
|
+
type: "ERROR",
|
|
132
|
+
error: err instanceof Error ? err : new Error(String(err))
|
|
133
|
+
});
|
|
99
134
|
}
|
|
100
135
|
});
|
|
101
136
|
}, [transform]);
|
|
102
|
-
return { data, loading, error, refetch };
|
|
137
|
+
return { data: state.data, loading: state.loading, error: state.error, refetch };
|
|
103
138
|
}
|
|
104
139
|
function useMutation(selector) {
|
|
105
140
|
const client = useLensClient();
|
package/package.json
CHANGED
package/src/context.tsx
CHANGED
|
@@ -2,20 +2,45 @@
|
|
|
2
2
|
* @sylphx/lens-react - Context Provider
|
|
3
3
|
*
|
|
4
4
|
* Provides Lens client to React component tree.
|
|
5
|
+
*
|
|
6
|
+
* Uses global singleton pattern to ensure the same context is shared
|
|
7
|
+
* across multiple module instances (important for monorepos where
|
|
8
|
+
* the same package may be resolved to different paths).
|
|
5
9
|
*/
|
|
6
10
|
|
|
7
11
|
import type { LensClient } from "@sylphx/lens-client";
|
|
8
12
|
import { createContext, type ReactElement, type ReactNode, useContext } from "react";
|
|
9
13
|
|
|
10
14
|
// =============================================================================
|
|
11
|
-
// Context
|
|
15
|
+
// Context (Global Singleton)
|
|
12
16
|
// =============================================================================
|
|
13
17
|
|
|
14
18
|
/**
|
|
15
|
-
*
|
|
19
|
+
* Global key for storing the singleton context.
|
|
20
|
+
* Using a Symbol ensures no collision with other globals.
|
|
21
|
+
*/
|
|
22
|
+
const LENS_CONTEXT_KEY = Symbol.for("@sylphx/lens-react/context");
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get or create the global singleton context.
|
|
26
|
+
* This ensures that even if the module is loaded multiple times
|
|
27
|
+
* (common in monorepos), all instances share the same React context.
|
|
28
|
+
*/
|
|
29
|
+
function getOrCreateContext(): React.Context<LensClient<any, any> | null> {
|
|
30
|
+
const globalObj = globalThis as unknown as Record<symbol, React.Context<LensClient<any, any> | null>>;
|
|
31
|
+
|
|
32
|
+
if (!globalObj[LENS_CONTEXT_KEY]) {
|
|
33
|
+
globalObj[LENS_CONTEXT_KEY] = createContext<LensClient<any, any> | null>(null);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return globalObj[LENS_CONTEXT_KEY];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Context for Lens client (singleton)
|
|
16
41
|
* Using any for internal storage to avoid type constraint issues
|
|
17
42
|
*/
|
|
18
|
-
const LensContext =
|
|
43
|
+
const LensContext = getOrCreateContext();
|
|
19
44
|
|
|
20
45
|
// =============================================================================
|
|
21
46
|
// Provider
|
package/src/hooks.ts
CHANGED
|
@@ -27,7 +27,15 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import type { LensClient, MutationResult, QueryResult } from "@sylphx/lens-client";
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
type DependencyList,
|
|
32
|
+
useCallback,
|
|
33
|
+
useEffect,
|
|
34
|
+
useMemo,
|
|
35
|
+
useReducer,
|
|
36
|
+
useRef,
|
|
37
|
+
useState,
|
|
38
|
+
} from "react";
|
|
31
39
|
import { useLensClient } from "./context.js";
|
|
32
40
|
|
|
33
41
|
// =============================================================================
|
|
@@ -68,6 +76,40 @@ export interface UseQueryOptions<TData = unknown, TSelected = TData> {
|
|
|
68
76
|
select?: (data: TData) => TSelected;
|
|
69
77
|
}
|
|
70
78
|
|
|
79
|
+
// =============================================================================
|
|
80
|
+
// Query State Reducer (for atomic state updates)
|
|
81
|
+
// =============================================================================
|
|
82
|
+
|
|
83
|
+
interface QueryState<T> {
|
|
84
|
+
data: T | null;
|
|
85
|
+
loading: boolean;
|
|
86
|
+
error: Error | null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
type QueryAction<T> =
|
|
90
|
+
| { type: "RESET" }
|
|
91
|
+
| { type: "START" }
|
|
92
|
+
| { type: "SUCCESS"; data: T }
|
|
93
|
+
| { type: "ERROR"; error: Error }
|
|
94
|
+
| { type: "LOADING_DONE" };
|
|
95
|
+
|
|
96
|
+
function queryReducer<T>(state: QueryState<T>, action: QueryAction<T>): QueryState<T> {
|
|
97
|
+
switch (action.type) {
|
|
98
|
+
case "RESET":
|
|
99
|
+
return { data: null, loading: false, error: null };
|
|
100
|
+
case "START":
|
|
101
|
+
return { ...state, loading: true, error: null };
|
|
102
|
+
case "SUCCESS":
|
|
103
|
+
return { data: action.data, loading: false, error: null };
|
|
104
|
+
case "ERROR":
|
|
105
|
+
return { ...state, loading: false, error: action.error };
|
|
106
|
+
case "LOADING_DONE":
|
|
107
|
+
return { ...state, loading: false };
|
|
108
|
+
default:
|
|
109
|
+
return state;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
71
113
|
/** Client type for callbacks */
|
|
72
114
|
type Client = LensClient<any, any>;
|
|
73
115
|
|
|
@@ -184,6 +226,10 @@ export function useQuery<TParams, TResult, TSelected = TResult>(
|
|
|
184
226
|
// Stable params key for Route + Params mode
|
|
185
227
|
const paramsKey = !isAccessorMode ? JSON.stringify(paramsOrDeps) : null;
|
|
186
228
|
|
|
229
|
+
// Use ref to track selector - avoids needing useCallback from users
|
|
230
|
+
const selectorRef = useRef(selector);
|
|
231
|
+
selectorRef.current = selector;
|
|
232
|
+
|
|
187
233
|
// Create query - memoized based on route/params or deps
|
|
188
234
|
const query = useMemo(
|
|
189
235
|
() => {
|
|
@@ -191,11 +237,11 @@ export function useQuery<TParams, TResult, TSelected = TResult>(
|
|
|
191
237
|
|
|
192
238
|
if (isAccessorMode) {
|
|
193
239
|
// Accessor mode: selector returns QueryResult directly
|
|
194
|
-
const querySelector =
|
|
240
|
+
const querySelector = selectorRef.current as QuerySelector<TResult>;
|
|
195
241
|
return querySelector(client);
|
|
196
242
|
}
|
|
197
243
|
// Route + Params mode: selector returns route function
|
|
198
|
-
const routeSelector =
|
|
244
|
+
const routeSelector = selectorRef.current as RouteSelector<TParams, TResult>;
|
|
199
245
|
const route = routeSelector(client);
|
|
200
246
|
if (!route) return null;
|
|
201
247
|
return route(paramsOrDeps as TParams);
|
|
@@ -205,16 +251,20 @@ export function useQuery<TParams, TResult, TSelected = TResult>(
|
|
|
205
251
|
? // eslint-disable-next-line react-hooks/exhaustive-deps
|
|
206
252
|
[client, options?.skip, ...(paramsOrDeps as DependencyList)]
|
|
207
253
|
: // eslint-disable-next-line react-hooks/exhaustive-deps
|
|
208
|
-
[client,
|
|
254
|
+
[client, paramsKey, options?.skip],
|
|
209
255
|
);
|
|
210
256
|
|
|
211
257
|
// Use ref for select to avoid it being a dependency
|
|
212
258
|
const selectRef = useRef(options?.select);
|
|
213
259
|
selectRef.current = options?.select;
|
|
214
260
|
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
|
|
261
|
+
// Use reducer for atomic state updates (prevents cascading re-renders)
|
|
262
|
+
const initialState: QueryState<TSelected> = {
|
|
263
|
+
data: null,
|
|
264
|
+
loading: query != null && !options?.skip,
|
|
265
|
+
error: null,
|
|
266
|
+
};
|
|
267
|
+
const [state, dispatch] = useReducer(queryReducer<TSelected>, initialState);
|
|
218
268
|
|
|
219
269
|
// Track mounted state
|
|
220
270
|
const mountedRef = useRef(true);
|
|
@@ -234,14 +284,11 @@ export function useQuery<TParams, TResult, TSelected = TResult>(
|
|
|
234
284
|
|
|
235
285
|
// Handle null/undefined query
|
|
236
286
|
if (query == null) {
|
|
237
|
-
|
|
238
|
-
setLoading(false);
|
|
239
|
-
setError(null);
|
|
287
|
+
dispatch({ type: "RESET" });
|
|
240
288
|
return;
|
|
241
289
|
}
|
|
242
290
|
|
|
243
|
-
|
|
244
|
-
setError(null);
|
|
291
|
+
dispatch({ type: "START" });
|
|
245
292
|
|
|
246
293
|
// Track if subscribe has provided data (to avoid duplicate updates from then)
|
|
247
294
|
let hasReceivedData = false;
|
|
@@ -250,8 +297,7 @@ export function useQuery<TParams, TResult, TSelected = TResult>(
|
|
|
250
297
|
const unsubscribe = query.subscribe((value) => {
|
|
251
298
|
if (mountedRef.current) {
|
|
252
299
|
hasReceivedData = true;
|
|
253
|
-
|
|
254
|
-
setLoading(false);
|
|
300
|
+
dispatch({ type: "SUCCESS", data: transform(value) });
|
|
255
301
|
}
|
|
256
302
|
});
|
|
257
303
|
|
|
@@ -259,17 +305,20 @@ export function useQuery<TParams, TResult, TSelected = TResult>(
|
|
|
259
305
|
// Only setData if subscribe hasn't already provided data (one-shot queries)
|
|
260
306
|
query.then(
|
|
261
307
|
(value) => {
|
|
262
|
-
if (mountedRef.current && !hasReceivedData) {
|
|
263
|
-
setData(transform(value));
|
|
264
|
-
}
|
|
265
308
|
if (mountedRef.current) {
|
|
266
|
-
|
|
309
|
+
if (!hasReceivedData) {
|
|
310
|
+
dispatch({ type: "SUCCESS", data: transform(value) });
|
|
311
|
+
} else {
|
|
312
|
+
dispatch({ type: "LOADING_DONE" });
|
|
313
|
+
}
|
|
267
314
|
}
|
|
268
315
|
},
|
|
269
316
|
(err) => {
|
|
270
317
|
if (mountedRef.current) {
|
|
271
|
-
|
|
272
|
-
|
|
318
|
+
dispatch({
|
|
319
|
+
type: "ERROR",
|
|
320
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
321
|
+
});
|
|
273
322
|
}
|
|
274
323
|
},
|
|
275
324
|
);
|
|
@@ -285,26 +334,26 @@ export function useQuery<TParams, TResult, TSelected = TResult>(
|
|
|
285
334
|
const currentQuery = queryRef.current;
|
|
286
335
|
if (currentQuery == null) return;
|
|
287
336
|
|
|
288
|
-
|
|
289
|
-
setError(null);
|
|
337
|
+
dispatch({ type: "START" });
|
|
290
338
|
|
|
291
339
|
currentQuery.then(
|
|
292
340
|
(value) => {
|
|
293
341
|
if (mountedRef.current) {
|
|
294
|
-
|
|
295
|
-
setLoading(false);
|
|
342
|
+
dispatch({ type: "SUCCESS", data: transform(value) });
|
|
296
343
|
}
|
|
297
344
|
},
|
|
298
345
|
(err) => {
|
|
299
346
|
if (mountedRef.current) {
|
|
300
|
-
|
|
301
|
-
|
|
347
|
+
dispatch({
|
|
348
|
+
type: "ERROR",
|
|
349
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
350
|
+
});
|
|
302
351
|
}
|
|
303
352
|
},
|
|
304
353
|
);
|
|
305
354
|
}, [transform]);
|
|
306
355
|
|
|
307
|
-
return { data, loading, error, refetch };
|
|
356
|
+
return { data: state.data, loading: state.loading, error: state.error, refetch };
|
|
308
357
|
}
|
|
309
358
|
|
|
310
359
|
// =============================================================================
|