@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 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 LensContext = createContext(null);
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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
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 = selector;
61
+ const querySelector = selectorRef.current;
29
62
  return querySelector(client);
30
63
  }
31
- const routeSelector = selector;
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, selector, paramsKey, options?.skip]);
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 [data, setData] = useState(null);
40
- const [loading, setLoading] = useState(query != null && !options?.skip);
41
- const [error, setError] = useState(null);
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
- setData(null);
52
- setLoading(false);
53
- setError(null);
87
+ dispatch({ type: "RESET" });
54
88
  return;
55
89
  }
56
- setLoading(true);
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
- setData(transform(value));
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
- setLoading(false);
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
- setError(err instanceof Error ? err : new Error(String(err)));
76
- setLoading(false);
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
- setLoading(true);
89
- setError(null);
123
+ dispatch({ type: "START" });
90
124
  currentQuery.then((value) => {
91
125
  if (mountedRef.current) {
92
- setData(transform(value));
93
- setLoading(false);
126
+ dispatch({ type: "SUCCESS", data: transform(value) });
94
127
  }
95
128
  }, (err) => {
96
129
  if (mountedRef.current) {
97
- setError(err instanceof Error ? err : new Error(String(err)));
98
- setLoading(false);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylphx/lens-react",
3
- "version": "2.1.4",
3
+ "version": "2.1.6",
4
4
  "description": "React bindings for Lens API framework",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
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
- * Context for Lens client
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 = createContext<LensClient<any, any> | null>(null);
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 { type DependencyList, useCallback, useEffect, useMemo, useRef, useState } from "react";
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 = selector as QuerySelector<TResult>;
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 = selector as RouteSelector<TParams, TResult>;
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, selector, paramsKey, options?.skip],
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
- const [data, setData] = useState<TSelected | null>(null);
216
- const [loading, setLoading] = useState(query != null && !options?.skip);
217
- const [error, setError] = useState<Error | null>(null);
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
- setData(null);
238
- setLoading(false);
239
- setError(null);
287
+ dispatch({ type: "RESET" });
240
288
  return;
241
289
  }
242
290
 
243
- setLoading(true);
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
- setData(transform(value));
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
- setLoading(false);
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
- setError(err instanceof Error ? err : new Error(String(err)));
272
- setLoading(false);
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
- setLoading(true);
289
- setError(null);
337
+ dispatch({ type: "START" });
290
338
 
291
339
  currentQuery.then(
292
340
  (value) => {
293
341
  if (mountedRef.current) {
294
- setData(transform(value));
295
- setLoading(false);
342
+ dispatch({ type: "SUCCESS", data: transform(value) });
296
343
  }
297
344
  },
298
345
  (err) => {
299
346
  if (mountedRef.current) {
300
- setError(err instanceof Error ? err : new Error(String(err)));
301
- setLoading(false);
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
  // =============================================================================