@chaosity/location-client-react 0.1.6 → 0.1.7

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.
@@ -1,10 +1,8 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import { GeoPlacesClient, ClientConfig } from '@chaosity/location-client';
3
3
  interface LocationClientContextValue {
4
- config: ClientConfig | null;
5
4
  client: GeoPlacesClient | null;
6
5
  getToken: () => string | undefined;
7
- ensureValidToken: () => Promise<void>;
8
6
  loading: boolean;
9
7
  error: string | null;
10
8
  }
@@ -13,6 +11,7 @@ export interface LocationClientProviderProps {
13
11
  getConfig: () => Promise<ClientConfig & {
14
12
  expiresAt?: number;
15
13
  }>;
14
+ /** Seconds before expiry to proactively refresh (default: 60) */
16
15
  refreshBuffer?: number;
17
16
  }
18
17
  export declare function LocationClientProvider({ children, getConfig, refreshBuffer }: LocationClientProviderProps): import("react/jsx-runtime").JSX.Element;
@@ -3,95 +3,92 @@ import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { createContext, useContext, useEffect, useState, useRef, useCallback } from 'react';
4
4
  import { GeoPlacesClient } from '@chaosity/location-client';
5
5
  import debug from 'debug';
6
- import { jwtDecode } from 'jwt-decode';
7
6
  const log = debug('location-client-react:provider');
8
7
  const LocationClientContext = createContext(undefined);
9
8
  export function LocationClientProvider({ children, getConfig, refreshBuffer = 60 }) {
10
- const [config, setConfig] = useState(null);
11
9
  const [client, setClient] = useState(null);
12
10
  const [loading, setLoading] = useState(true);
13
11
  const [error, setError] = useState(null);
12
+ // Refs hold live values without triggering re-renders
14
13
  const tokenRef = useRef(undefined);
15
14
  const expiresAtRef = useRef(null);
16
15
  const getConfigRef = useRef(getConfig);
17
- const tokenGenerationCount = useRef(0);
18
- // Update ref when getConfig changes
16
+ const refreshPromiseRef = useRef(null);
17
+ const ensureValidTokenRef = useRef(async () => { });
18
+ // Keep getConfig ref current without recreating callbacks
19
19
  useEffect(() => {
20
20
  getConfigRef.current = getConfig;
21
21
  }, [getConfig]);
22
- // Token getter function (stable reference)
22
+ // Stable token getter — passed into GeoPlacesClient so it always reads the live ref
23
23
  const getToken = useCallback(() => tokenRef.current, []);
24
- // Check if token is expired or about to expire
24
+ // Returns true when the token is expired or within the refresh buffer window.
25
+ // Treats unknown expiry as expired so we always refresh on first use.
25
26
  const isTokenExpired = useCallback(() => {
26
27
  if (!expiresAtRef.current)
27
- return false;
28
+ return true;
28
29
  return Date.now() >= (expiresAtRef.current - refreshBuffer * 1000);
29
30
  }, [refreshBuffer]);
30
- // Refresh token if needed
31
+ // Refreshes the token exactly once even when called concurrently.
32
+ // All concurrent callers await the same in-flight promise — same pattern as
33
+ // server-side TokenProvider.tokenPromise deduplication.
31
34
  const ensureValidToken = useCallback(async () => {
32
35
  if (!isTokenExpired())
33
36
  return;
34
- const timeUntilExpiry = expiresAtRef.current ? Math.floor((expiresAtRef.current - Date.now()) / 1000) : 0;
35
- log('Token expired or expiring soon (expires in %ds), refreshing...', timeUntilExpiry);
36
- tokenGenerationCount.current++;
37
- try {
37
+ // Deduplicate: if a refresh is already in flight, wait for it instead of firing another
38
+ if (refreshPromiseRef.current) {
39
+ log('Token refresh already in progress, waiting...');
40
+ return refreshPromiseRef.current;
41
+ }
42
+ const timeUntilExpiry = expiresAtRef.current
43
+ ? Math.floor((expiresAtRef.current - Date.now()) / 1000)
44
+ : 0;
45
+ log('Token expired or expiring soon (in %ds), refreshing...', timeUntilExpiry);
46
+ refreshPromiseRef.current = (async () => {
38
47
  const cfg = await getConfigRef.current();
39
- setConfig(cfg);
40
48
  tokenRef.current = cfg.token;
41
- expiresAtRef.current = null;
42
- const baseClient = new GeoPlacesClient(cfg);
43
- const wrappedClient = new Proxy(baseClient, {
44
- get(target, prop) {
45
- if (prop === 'send') {
46
- return async (command) => {
47
- await ensureValidToken();
48
- return target.send(command);
49
- };
50
- }
51
- return target[prop];
52
- }
53
- });
54
- setClient(wrappedClient);
55
- const decoded = jwtDecode(cfg.token);
56
- expiresAtRef.current = decoded.exp * 1000;
57
- const newExpiry = Math.floor((decoded.exp * 1000 - Date.now()) / 1000);
58
- log('Token refreshed successfully (new token expires in %ds)', newExpiry);
49
+ expiresAtRef.current = cfg.expiresAt ?? (Date.now() + 900000);
50
+ const newExpiry = Math.floor((expiresAtRef.current - Date.now()) / 1000);
51
+ log('Token refreshed (expires in %ds)', newExpiry);
52
+ })();
53
+ try {
54
+ await refreshPromiseRef.current;
59
55
  }
60
56
  catch (err) {
61
57
  log('Token refresh failed: %s', err instanceof Error ? err.message : 'Unknown error');
62
58
  setError(err instanceof Error ? err.message : 'Failed to refresh token');
63
59
  }
60
+ finally {
61
+ refreshPromiseRef.current = null;
62
+ }
64
63
  }, [isTokenExpired]);
65
- // Initial load
64
+ // Keep ensureValidToken ref current so the send wrapper always uses the latest version
65
+ useEffect(() => {
66
+ ensureValidTokenRef.current = ensureValidToken;
67
+ }, [ensureValidToken]);
68
+ // Initial load — create the client once.
69
+ // getToken is passed as a callback so the client always reads the live token ref.
70
+ // ensureValidToken is called before each send so expiring tokens are refreshed
71
+ // transparently without recreating the client or causing re-renders.
66
72
  useEffect(() => {
67
73
  log('Initializing LocationClientProvider');
68
- tokenGenerationCount.current++;
69
- getConfig()
74
+ getConfigRef.current()
70
75
  .then((cfg) => {
71
- setConfig(cfg);
72
76
  tokenRef.current = cfg.token;
73
- const baseClient = new GeoPlacesClient(cfg);
74
- const wrappedClient = new Proxy(baseClient, {
75
- get(target, prop) {
76
- if (prop === 'send') {
77
- return async (command) => {
78
- await ensureValidToken();
79
- return target.send(command);
80
- };
81
- }
82
- return target[prop];
83
- }
84
- });
85
- setClient(wrappedClient);
86
- try {
87
- const decoded = jwtDecode(cfg.token);
88
- expiresAtRef.current = decoded.exp * 1000;
89
- const expiry = Math.floor((decoded.exp * 1000 - Date.now()) / 1000);
90
- log('Client initialized (token expires in %ds)', expiry);
91
- }
92
- catch (err) {
93
- log('Failed to decode token, no expiry info');
94
- }
77
+ expiresAtRef.current = cfg.expiresAt ?? (Date.now() + 900000);
78
+ // GeoPlacesClient.send() calls getToken() synchronously for the token value,
79
+ // but token refresh is async. We create a thin send wrapper that:
80
+ // 1. awaits ensureValidToken (via ref — always latest, race-safe)
81
+ // 2. delegates to baseClient which reads the now-fresh token from getToken()
82
+ // The client instance is created once and never recreated on token refresh.
83
+ const baseClient = new GeoPlacesClient({ apiUrl: cfg.apiUrl, token: cfg.token, getToken });
84
+ const wrappingClient = Object.create(baseClient);
85
+ wrappingClient.send = async (command) => {
86
+ await ensureValidTokenRef.current();
87
+ return baseClient.send(command);
88
+ };
89
+ setClient(wrappingClient);
90
+ const expiry = Math.floor((expiresAtRef.current - Date.now()) / 1000);
91
+ log('Client initialized (token expires in %ds)', expiry);
95
92
  setLoading(false);
96
93
  })
97
94
  .catch((err) => {
@@ -99,14 +96,8 @@ export function LocationClientProvider({ children, getConfig, refreshBuffer = 60
99
96
  setError(err instanceof Error ? err.message : 'Failed to initialize client');
100
97
  setLoading(false);
101
98
  });
102
- }, [ensureValidToken]);
103
- // Check token validity on every state change
104
- useEffect(() => {
105
- if (!loading && config) {
106
- ensureValidToken();
107
- }
108
- }, [config, loading, ensureValidToken]);
109
- return (_jsx(LocationClientContext.Provider, { value: { config, client, getToken, ensureValidToken, loading, error }, children: children }));
99
+ }, [getToken]);
100
+ return (_jsx(LocationClientContext.Provider, { value: { client, getToken, loading, error }, children: children }));
110
101
  }
111
102
  export function useLocationClient() {
112
103
  const context = useContext(LocationClientContext);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chaosity/location-client-react",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "React bindings for Chaosity Location Service client",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -8,6 +8,8 @@
8
8
  "scripts": {
9
9
  "build": "tsc",
10
10
  "dev": "tsc --watch",
11
+ "test": "vitest run",
12
+ "test:watch": "vitest",
11
13
  "prepublishOnly": "npm run build"
12
14
  },
13
15
  "keywords": [
@@ -28,18 +30,24 @@
28
30
  "url": "https://github.com/chaosity-io/location-service-client-react/issues"
29
31
  },
30
32
  "dependencies": {
31
- "@chaosity/location-client": "^0.1.5",
32
- "debug": "^4.4.3",
33
- "jwt-decode": "^4.0.0"
33
+ "@chaosity/location-client": "file:../location-service-client",
34
+ "debug": "^4.4.3"
34
35
  },
35
36
  "peerDependencies": {
36
37
  "react": "^18.0.0 || ^19.0.0"
37
38
  },
38
39
  "devDependencies": {
40
+ "@testing-library/react": "^16.0.0",
41
+ "@testing-library/user-event": "^14.0.0",
39
42
  "@types/debug": "^4.1.12",
40
43
  "@types/react": "^19.0.0",
44
+ "happy-dom": "^17.0.0",
41
45
  "react": "^19.0.0",
42
- "typescript": "^5.0.0"
46
+ "typescript": "^5.0.0",
47
+ "vitest": "^3.0.0"
48
+ },
49
+ "publishConfig": {
50
+ "access": "public"
43
51
  },
44
52
  "files": [
45
53
  "dist",