@chaosity/location-client-react 0.1.19 → 0.3.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 CHANGED
@@ -50,14 +50,17 @@ export default function RootLayout({
50
50
 
51
51
  ```tsx
52
52
  import { useLocationClient } from '@chaosity/location-client-react'
53
- import { SuggestCommand } from '@chaosity/location-client'
53
+ import {
54
+ SuggestCommand,
55
+ type SuggestCommandOutput,
56
+ } from '@chaosity/location-client'
54
57
 
55
58
  function SearchComponent() {
56
59
  const { client, loading, error } = useLocationClient()
57
60
 
58
61
  const searchPlaces = async (query: string) => {
59
62
  if (!client) return
60
- const response = await client.send(
63
+ const response: SuggestCommandOutput = await client.send(
61
64
  new SuggestCommand({ QueryText: query, MaxResults: 5 }),
62
65
  )
63
66
  return response.ResultItems
@@ -87,9 +90,7 @@ function MapComponent() {
87
90
  useMapLanguage(mapInstance, language)
88
91
 
89
92
  useEffect(() => {
90
- const map = new maplibregl.Map({
91
- /* ... */
92
- })
93
+ const map = new maplibregl.Map({/* ... */})
93
94
  map.once('load', () => setMapInstance(map))
94
95
  return () => map.remove()
95
96
  }, [])
@@ -261,6 +262,7 @@ All AWS Location Service commands are available through the client:
261
262
  ```tsx
262
263
  import {
263
264
  SuggestCommand,
265
+ type SuggestCommandOutput,
264
266
  GeocodeCommand,
265
267
  ReverseGeocodeCommand,
266
268
  GetPlaceCommand,
@@ -272,7 +274,7 @@ function MyComponent() {
272
274
  const { client } = useLocationClient()
273
275
 
274
276
  const search = async () => {
275
- const response = await client!.send(
277
+ const response: SuggestCommandOutput = await client!.send(
276
278
  new SuggestCommand({ QueryText: 'Vancouver', MaxResults: 5 }),
277
279
  )
278
280
  return response.ResultItems
@@ -293,7 +295,10 @@ DEBUG=location-client-react:* npm run dev
293
295
  Full TypeScript support with types from AWS SDK:
294
296
 
295
297
  ```tsx
296
- import type { SuggestCommandOutput } from '@aws-sdk/client-geo-places'
298
+ import {
299
+ SuggestCommand,
300
+ type SuggestCommandOutput,
301
+ } from '@chaosity/location-client'
297
302
 
298
303
  const { client } = useLocationClient()
299
304
  const response: SuggestCommandOutput = await client!.send(
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  export { LocationClientProvider, useLocationClient, } from './provider/LocationClientProvider';
2
- export type { LocationClientProviderProps } from './provider/LocationClientProvider';
2
+ export type { LocationClient, LocationClientProviderProps, SendOptions, } from './provider/LocationClientProvider';
3
3
  export { useMapLanguage } from './hooks/useMapLanguage';
@@ -1,8 +1,34 @@
1
1
  import type { ClientConfig } from '@chaosity/location-client';
2
- import { GeoPlacesClient } from '@chaosity/location-client';
3
2
  import type { ReactNode } from 'react';
3
+ /**
4
+ * Per-request transport options.
5
+ *
6
+ * Declared structurally rather than imported so this package builds against the
7
+ * currently published client; it matches `SendOptions` there exactly.
8
+ */
9
+ export interface SendOptions {
10
+ signal?: AbortSignal;
11
+ timeoutMs?: number;
12
+ retry?: false | {
13
+ maxAttempts?: number;
14
+ };
15
+ }
16
+ /**
17
+ * What the provider hands out.
18
+ *
19
+ * An interface rather than `GeoPlacesClient` because the provider wraps the
20
+ * real client to refresh tokens first, and a class with private fields is not
21
+ * structurally assignable — which is why this used to be an `Object.create`
22
+ * prototype hack.
23
+ */
24
+ export interface LocationClient {
25
+ readonly config: {
26
+ serviceId: string;
27
+ };
28
+ send<TInput, TOutput>(command: TInput, options?: SendOptions): Promise<TOutput>;
29
+ }
4
30
  interface LocationClientContextValue {
5
- client: GeoPlacesClient | null;
31
+ client: LocationClient | null;
6
32
  getToken: () => string | undefined;
7
33
  loading: boolean;
8
34
  error: string | null;
@@ -12,9 +38,7 @@ export interface LocationClientProviderProps {
12
38
  getConfig: () => Promise<ClientConfig & {
13
39
  expiresAt?: number;
14
40
  }>;
15
- /** Seconds before expiry to proactively refresh (default: 60) */
16
- refreshBuffer?: number;
17
41
  }
18
- export declare function LocationClientProvider({ children, getConfig, refreshBuffer, }: LocationClientProviderProps): import("react/jsx-runtime").JSX.Element;
42
+ export declare function LocationClientProvider({ children, getConfig, }: LocationClientProviderProps): import("react").JSX.Element;
19
43
  export declare function useLocationClient(): LocationClientContextValue;
20
44
  export {};
@@ -1,106 +1,194 @@
1
1
  'use client';
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
- import { GeoPlacesClient } from '@chaosity/location-client';
3
+ import { GeoPlacesClient, TOKEN_REFRESH_BUFFER_SECONDS, readTokenExpiry, } from '@chaosity/location-client';
4
4
  import debug from 'debug';
5
5
  import { createContext, useCallback, useContext, useEffect, useRef, useState, } from 'react';
6
6
  const log = debug('location-client-react:provider');
7
7
  const LocationClientContext = createContext(undefined);
8
- export function LocationClientProvider({ children, getConfig, refreshBuffer = 60, }) {
8
+ const DEFAULT_LIFETIME_MS = 900000;
9
+ /**
10
+ * When this token needs replacing.
11
+ *
12
+ * The `exp` claim first — it is the only value that cannot disagree with what
13
+ * the API will accept, and the server-side TokenProvider reads the same one.
14
+ * `expiresAt` is whatever `getConfig` chose to report, and the final fallback
15
+ * is a guess used only when the token cannot be parsed at all.
16
+ */
17
+ function expiryOf(cfg) {
18
+ return (readTokenExpiry(cfg.token) ??
19
+ cfg.expiresAt ??
20
+ Date.now() + DEFAULT_LIFETIME_MS);
21
+ }
22
+ export function LocationClientProvider({ children, getConfig, }) {
9
23
  const [client, setClient] = useState(null);
10
24
  const [loading, setLoading] = useState(true);
11
25
  const [error, setError] = useState(null);
12
- // Refs hold live values without triggering re-renders
13
26
  const tokenRef = useRef(undefined);
14
27
  const expiresAtRef = useRef(null);
15
28
  const getConfigRef = useRef(getConfig);
16
29
  const refreshPromiseRef = useRef(null);
17
- const ensureValidTokenRef = useRef(async () => { });
18
- // Keep getConfig ref current without recreating callbacks
30
+ const timerRef = useRef(null);
31
+ const mountedRef = useRef(true);
19
32
  useEffect(() => {
20
33
  getConfigRef.current = getConfig;
21
34
  }, [getConfig]);
22
- // Stable token getter — passed into GeoPlacesClient so it always reads the live ref
23
- const getToken = useCallback(() => tokenRef.current, []);
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.
26
35
  const isTokenExpired = useCallback(() => {
27
36
  if (!expiresAtRef.current)
28
37
  return true;
29
- return Date.now() >= expiresAtRef.current - refreshBuffer * 1000;
30
- }, [refreshBuffer]);
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.
34
- const ensureValidToken = useCallback(async () => {
35
- if (!isTokenExpired())
36
- return;
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...');
38
+ return (Date.now() >= expiresAtRef.current - TOKEN_REFRESH_BUFFER_SECONDS * 1000);
39
+ }, []);
40
+ /**
41
+ * Refresh once, however many callers ask at the same moment.
42
+ *
43
+ * REJECTS on failure. It used to swallow the error into state and resolve,
44
+ * so `send` carried on with the token it already had — guaranteeing a 401 on
45
+ * the very next call and reporting it as an API error rather than a refresh
46
+ * failure.
47
+ */
48
+ const refreshToken = useCallback(async () => {
49
+ if (refreshPromiseRef.current)
40
50
  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
51
  refreshPromiseRef.current = (async () => {
47
52
  const cfg = await getConfigRef.current();
48
53
  tokenRef.current = cfg.token;
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);
54
+ expiresAtRef.current = expiryOf(cfg);
55
+ log('Token refreshed (expires in %ds)', Math.floor((expiresAtRef.current - Date.now()) / 1000));
56
+ if (mountedRef.current)
57
+ setError(null);
52
58
  })();
53
59
  try {
54
60
  await refreshPromiseRef.current;
61
+ scheduleRefreshRef.current();
55
62
  }
56
63
  catch (err) {
57
- log('Token refresh failed: %s', err instanceof Error ? err.message : 'Unknown error');
58
- setError(err instanceof Error ? err.message : 'Failed to refresh token');
64
+ const message = err instanceof Error ? err.message : 'Failed to refresh token';
65
+ log('Token refresh failed: %s', message);
66
+ if (mountedRef.current)
67
+ setError(message);
68
+ throw err;
59
69
  }
60
70
  finally {
61
71
  refreshPromiseRef.current = null;
62
72
  }
63
- }, [isTokenExpired]);
64
- // Keep ensureValidToken ref current so the send wrapper always uses the latest version
73
+ }, []);
74
+ /**
75
+ * Refresh AHEAD of expiry, on a timer.
76
+ *
77
+ * This is the whole fix for the map path. MapLibre's `transformRequest` is
78
+ * synchronous by contract, so `getToken` cannot await anything — the token it
79
+ * reads has to be valid already. Refresh used to happen only inside the `send`
80
+ * wrapper, which the map never calls: it requests tiles, glyphs and sprites
81
+ * directly. So after 15 minutes every map request failed, for as long as the
82
+ * page stayed open, and no amount of panning recovered it.
83
+ */
84
+ const scheduleRefresh = useCallback(() => {
85
+ if (timerRef.current)
86
+ clearTimeout(timerRef.current);
87
+ if (!expiresAtRef.current)
88
+ return;
89
+ const delay = Math.max(0, expiresAtRef.current - TOKEN_REFRESH_BUFFER_SECONDS * 1000 - Date.now());
90
+ log('Next refresh in %ds', Math.floor(delay / 1000));
91
+ timerRef.current = setTimeout(() => {
92
+ // Errors are already surfaced onto state by refreshToken; swallow here so
93
+ // a failed background refresh cannot become an unhandled rejection.
94
+ void refreshToken().catch(() => { });
95
+ }, delay);
96
+ }, [refreshToken]);
97
+ // refreshToken and scheduleRefresh reference each other; a ref breaks the cycle
98
+ // without recreating either callback on every render.
99
+ const scheduleRefreshRef = useRef(() => { });
100
+ useEffect(() => {
101
+ scheduleRefreshRef.current = scheduleRefresh;
102
+ }, [scheduleRefresh]);
103
+ /**
104
+ * Synchronous read for the map path.
105
+ *
106
+ * If the token is already stale — a timer that never fired because the tab was
107
+ * backgrounded and throttled — this kicks off a refresh but cannot wait for it.
108
+ * The current read still returns the stale value; the point is that the NEXT
109
+ * one will not.
110
+ */
111
+ const getToken = useCallback(() => {
112
+ // `tokenRef.current` guards the pre-initialisation window: until the first
113
+ // config load lands there is no expiry to judge, and firing here would race
114
+ // the initial fetch and request a second token nobody asked for.
115
+ if (tokenRef.current && isTokenExpired() && !refreshPromiseRef.current) {
116
+ log('Stale token read — refreshing in the background');
117
+ void refreshToken().catch(() => { });
118
+ }
119
+ return tokenRef.current;
120
+ }, [isTokenExpired, refreshToken]);
121
+ const ensureValidToken = useCallback(async () => {
122
+ if (!isTokenExpired())
123
+ return;
124
+ await refreshToken();
125
+ }, [isTokenExpired, refreshToken]);
126
+ const ensureValidTokenRef = useRef(ensureValidToken);
65
127
  useEffect(() => {
66
128
  ensureValidTokenRef.current = ensureValidToken;
67
129
  }, [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.
130
+ /**
131
+ * A backgrounded tab has its timers throttled, so the scheduled refresh can be
132
+ * arbitrarily late. Refresh on the way back in, before the user touches the map.
133
+ */
72
134
  useEffect(() => {
135
+ if (typeof document === 'undefined')
136
+ return;
137
+ const onVisible = () => {
138
+ if (document.visibilityState === 'visible' &&
139
+ tokenRef.current &&
140
+ isTokenExpired()) {
141
+ log('Tab visible again with a stale token — refreshing');
142
+ void refreshToken().catch(() => { });
143
+ }
144
+ };
145
+ document.addEventListener('visibilitychange', onVisible);
146
+ return () => document.removeEventListener('visibilitychange', onVisible);
147
+ }, [isTokenExpired, refreshToken]);
148
+ useEffect(() => {
149
+ mountedRef.current = true;
73
150
  log('Initializing LocationClientProvider');
74
151
  getConfigRef
75
152
  .current()
76
153
  .then((cfg) => {
154
+ if (!mountedRef.current)
155
+ return;
77
156
  tokenRef.current = cfg.token;
78
- expiresAtRef.current = cfg.expiresAt ?? Date.now() + 900000;
79
- // GeoPlacesClient.send() calls getToken() synchronously for the token value,
80
- // but token refresh is async. We create a thin send wrapper that:
81
- // 1. awaits ensureValidToken (via ref — always latest, race-safe)
82
- // 2. delegates to baseClient which reads the now-fresh token from getToken()
83
- // The client instance is created once and never recreated on token refresh.
157
+ expiresAtRef.current = expiryOf(cfg);
84
158
  const baseClient = new GeoPlacesClient({
85
159
  apiUrl: cfg.apiUrl,
86
160
  token: cfg.token,
87
161
  getToken,
88
162
  });
89
- const wrappingClient = Object.create(baseClient);
90
- wrappingClient.send = (async (command) => {
91
- await ensureValidTokenRef.current();
92
- return baseClient.send(command);
93
- });
94
- setClient(wrappingClient);
95
- const expiry = Math.floor((expiresAtRef.current - Date.now()) / 1000);
96
- log('Client initialized (token expires in %ds)', expiry);
163
+ // A plain object, not Object.create(baseClient): the prototype hack was
164
+ // opaque, and its `send` dropped the second argument entirely — so once
165
+ // the client gained `signal`/`timeoutMs`, every option passed through
166
+ // this provider would have been silently discarded.
167
+ const refreshing = {
168
+ config: baseClient.config,
169
+ async send(command, options) {
170
+ await ensureValidTokenRef.current();
171
+ return baseClient.send(command, options);
172
+ },
173
+ };
174
+ setClient(refreshing);
175
+ scheduleRefreshRef.current();
176
+ log('Client initialized (token expires in %ds)', Math.floor((expiresAtRef.current - Date.now()) / 1000));
97
177
  setLoading(false);
98
178
  })
99
179
  .catch((err) => {
100
- log('Initialization failed: %s', err instanceof Error ? err.message : 'Unknown error');
101
- setError(err instanceof Error ? err.message : 'Failed to initialize client');
180
+ if (!mountedRef.current)
181
+ return;
182
+ const message = err instanceof Error ? err.message : 'Failed to initialize client';
183
+ log('Initialization failed: %s', message);
184
+ setError(message);
102
185
  setLoading(false);
103
186
  });
187
+ return () => {
188
+ mountedRef.current = false;
189
+ if (timerRef.current)
190
+ clearTimeout(timerRef.current);
191
+ };
104
192
  }, [getToken]);
105
193
  return (_jsx(LocationClientContext.Provider, { value: { client, getToken, loading, error }, children: children }));
106
194
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chaosity/location-client-react",
3
- "version": "0.1.19",
3
+ "version": "0.3.0",
4
4
  "description": "React bindings for Chaosity Location Service client",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -11,7 +11,7 @@
11
11
  "lint": "eslint . && prettier --check .",
12
12
  "lint:fix": "eslint --fix . && prettier --write .",
13
13
  "format": "prettier --write .",
14
- "test": "vitest run --passWithNoTests",
14
+ "test": "vitest run",
15
15
  "test:watch": "vitest",
16
16
  "prepublishOnly": "npm run build",
17
17
  "prepare": "husky"
@@ -34,7 +34,7 @@
34
34
  "url": "https://github.com/chaosity-io/location-service-client-react/issues"
35
35
  },
36
36
  "dependencies": {
37
- "@chaosity/location-client": "^0.1.14",
37
+ "@chaosity/location-client": "^0.2.1",
38
38
  "debug": "^4.4.3"
39
39
  },
40
40
  "peerDependencies": {
@@ -54,7 +54,7 @@
54
54
  "@types/react": "^19.0.0",
55
55
  "@vitest/coverage-v8": "^3.0.0",
56
56
  "eslint": "^10.1.0",
57
- "happy-dom": "^17.0.0",
57
+ "happy-dom": "^20.11.6",
58
58
  "husky": "^9.0.0",
59
59
  "maplibre-gl": "^5.0.0",
60
60
  "prettier": "^3.8.1",