react-native-google-places-autocomplete 2.6.4 → 2.6.5

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,79 +1,136 @@
1
- /* eslint-disable react-native/no-inline-styles */
2
- import debounce from 'lodash.debounce';
3
- import Qs from 'qs';
4
- import uuid from 'react-native-uuid';
5
1
  import React, {
6
2
  forwardRef,
7
- useMemo,
3
+ useCallback,
8
4
  useEffect,
9
5
  useImperativeHandle,
6
+ useMemo,
10
7
  useRef,
11
8
  useState,
12
- useCallback,
13
9
  } from 'react';
14
10
  import {
15
- ActivityIndicator,
16
11
  FlatList,
17
12
  Image,
18
13
  Keyboard,
19
14
  Platform,
20
- Pressable,
21
- ScrollView,
22
- StyleSheet,
23
- Text,
24
15
  TextInput,
25
16
  View,
26
17
  } from 'react-native';
27
18
 
19
+ import Row from './src/Row';
20
+ import { debounce as createDebounce } from './src/debounce';
21
+ import { stringify } from './src/queryString';
22
+ import {
23
+ buildRowsFromResults,
24
+ filterResultsByPlacePredictions,
25
+ filterResultsByTypes,
26
+ getRowKey,
27
+ } from './src/results';
28
+ import { mergeStyles } from './src/styles';
29
+ import { uuidv4 } from './src/uuid';
30
+
28
31
  // ============================================================================
29
32
  // CONSTANTS
33
+ //
34
+ // Every default that is an object or an array lives out here. Declaring them
35
+ // inline in the destructuring pattern created a fresh identity on every render,
36
+ // which silently defeated every useMemo/useCallback downstream of them.
30
37
  // ============================================================================
31
38
 
32
- const defaultStyles = {
33
- container: {
34
- flex: 1,
35
- },
36
- textInputContainer: {
37
- flexDirection: 'row',
38
- },
39
- textInput: {
40
- backgroundColor: '#FFFFFF',
41
- height: 44,
42
- borderRadius: 5,
43
- paddingVertical: 5,
44
- paddingHorizontal: 10,
45
- fontSize: 15,
46
- flex: 1,
47
- marginBottom: 5,
48
- },
49
- listView: {
50
- backgroundColor: '#FFFFFF',
51
- },
52
- row: {
53
- backgroundColor: '#FFFFFF',
54
- padding: 13,
55
- minHeight: 44,
56
- flexDirection: 'row',
57
- },
58
- loader: {
59
- flexDirection: 'row',
60
- justifyContent: 'flex-end',
61
- height: 20,
62
- },
63
- description: {},
64
- separator: {
65
- height: StyleSheet.hairlineWidth,
66
- backgroundColor: '#c8c7cc',
67
- },
68
- poweredContainer: {
69
- justifyContent: 'flex-end',
70
- alignItems: 'center',
71
- borderBottomRightRadius: 5,
72
- borderBottomLeftRadius: 5,
73
- borderColor: '#c8c7cc',
74
- borderTopWidth: 0.5,
75
- },
76
- powered: {},
39
+ const GOOGLE_API_BASE = 'https://maps.googleapis.com/maps/api';
40
+
41
+ const EMPTY_ARRAY = Object.freeze([]);
42
+ const EMPTY_OBJECT = Object.freeze({});
43
+
44
+ const DEFAULT_QUERY = Object.freeze({
45
+ key: 'missing api key',
46
+ language: 'en',
47
+ types: 'geocode',
48
+ });
49
+
50
+ const DEFAULT_SEARCH_QUERY = Object.freeze({
51
+ rankby: 'distance',
52
+ type: 'restaurant',
53
+ });
54
+
55
+ const noop = () => {};
56
+
57
+ const defaultOnTimeout = () =>
58
+ console.warn('google places autocomplete: request timeout');
59
+
60
+ const getRequestUrl = (requestUrl) => {
61
+ if (requestUrl) {
62
+ if (requestUrl.useOnPlatform === 'all') {
63
+ return requestUrl.url;
64
+ }
65
+ if (requestUrl.useOnPlatform === 'web') {
66
+ return Platform.select({ web: requestUrl.url, default: GOOGLE_API_BASE });
67
+ }
68
+ }
69
+ return GOOGLE_API_BASE;
70
+ };
71
+
72
+ /**
73
+ * Detaching the handler before aborting keeps `abort()` from reporting itself
74
+ * as a transport failure. Nothing can be observed about a request after this,
75
+ * so only ever call it on a request the component has decided to abandon.
76
+ */
77
+ const abortRequest = (request) => {
78
+ request.onreadystatechange = null;
79
+ request.abort();
80
+ };
81
+
82
+ const setRequestHeaders = (request, headers) => {
83
+ if (!headers) {
84
+ return;
85
+ }
86
+ Object.keys(headers).forEach((key) =>
87
+ request.setRequestHeader(key, headers[key]),
88
+ );
89
+ };
90
+
91
+ /**
92
+ * Resolve the geolocation provider *without* detaching the method from its
93
+ * receiver — class-based implementations rely on `this`.
94
+ */
95
+ const getGeolocationProvider = () => {
96
+ const geolocation =
97
+ typeof navigator !== 'undefined' ? navigator.geolocation : undefined;
98
+
99
+ if (geolocation?.getCurrentPosition) {
100
+ return geolocation;
101
+ }
102
+ if (geolocation?.default?.getCurrentPosition) {
103
+ return geolocation.default;
104
+ }
105
+ return null;
106
+ };
107
+
108
+ /** Only the results that came from the API earn the "powered by Google" logo. */
109
+ const hasApiResults = (rows) => {
110
+ for (let i = 0; i < rows.length; i++) {
111
+ if (
112
+ !('isCurrentLocation' in rows[i]) &&
113
+ !('isPredefinedPlace' in rows[i])
114
+ ) {
115
+ return true;
116
+ }
117
+ }
118
+ return false;
119
+ };
120
+
121
+ const isFocusInsideResultList = ({ relatedTarget }) => {
122
+ if (!relatedTarget) {
123
+ return false;
124
+ }
125
+
126
+ let node = relatedTarget.parentNode;
127
+ while (node) {
128
+ if (node.id === 'result-list-id') {
129
+ return true;
130
+ }
131
+ node = node.parentNode;
132
+ }
133
+ return false;
77
134
  };
78
135
 
79
136
  // ============================================================================
@@ -81,11 +138,9 @@ const defaultStyles = {
81
138
  // ============================================================================
82
139
 
83
140
  export const GooglePlacesAutocomplete = forwardRef((props, ref) => {
84
- // ==========================================================================
85
- // PROPS DESTRUCTURING
86
- // ==========================================================================
87
141
  const {
88
142
  autoFillOnNotFound = false,
143
+ children,
89
144
  currentLocation = false,
90
145
  currentLocationLabel = 'Current location',
91
146
  debounce: debounceMs = 0,
@@ -93,655 +148,570 @@ export const GooglePlacesAutocomplete = forwardRef((props, ref) => {
93
148
  enableHighAccuracyLocation = true,
94
149
  enablePoweredByContainer = true,
95
150
  fetchDetails = false,
96
- filterReverseGeocodingByTypes = [],
97
- GooglePlacesDetailsQuery = {},
98
- GooglePlacesSearchQuery = {
99
- rankby: 'distance',
100
- type: 'restaurant',
101
- },
102
- GoogleReverseGeocodingQuery = {},
151
+ fields = '*',
152
+ filterReverseGeocodingByTypes = EMPTY_ARRAY,
153
+ GooglePlacesDetailsQuery = EMPTY_OBJECT,
154
+ GooglePlacesSearchQuery = DEFAULT_SEARCH_QUERY,
155
+ GoogleReverseGeocodingQuery = EMPTY_OBJECT,
156
+ inbetweenCompo,
157
+ isNewPlacesAPI = false,
103
158
  isRowScrollable = true,
159
+ keepResultsAfterBlur = false,
104
160
  keyboardShouldPersistTaps = 'always',
161
+ listEmptyComponent,
105
162
  listHoverColor = '#ececec',
163
+ listLoaderComponent,
106
164
  listUnderlayColor = '#c8c7cc',
107
165
  listViewDisplayed: listViewDisplayedProp = 'auto',
108
- keepResultsAfterBlur = false,
109
166
  minLength = 0,
110
167
  nearbyPlacesAPI = 'GooglePlacesSearch',
111
168
  numberOfLines = 1,
112
- onFail = () => {},
113
- onNotFound = () => {},
114
- onPress = () => {},
115
- onTimeout = () =>
116
- console.warn('google places autocomplete: request timeout'),
169
+ onFail,
170
+ onNotFound,
171
+ onPress: onPressProp = noop,
172
+ onTimeout = defaultOnTimeout,
117
173
  placeholder = '',
118
- predefinedPlaces: predefinedPlacesProp = [],
174
+ predefinedPlaces = EMPTY_ARRAY,
119
175
  predefinedPlacesAlwaysVisible = false,
120
- query = {
121
- key: 'missing api key',
122
- language: 'en',
123
- types: 'geocode',
124
- },
125
- styles = {},
176
+ preProcess,
177
+ query = DEFAULT_QUERY,
178
+ renderDescription,
179
+ renderHeaderComponent,
180
+ renderLeftButton,
181
+ renderRightButton,
182
+ renderRow,
183
+ requestUrl,
184
+ styles = EMPTY_OBJECT,
126
185
  suppressDefaultStyles = false,
127
186
  textInputHide = false,
128
- textInputProps = {},
187
+ textInputProps = EMPTY_OBJECT,
129
188
  timeout = 20000,
130
- isNewPlacesAPI = false,
131
- fields = '*',
132
189
  ...restProps
133
190
  } = props;
134
191
 
135
- // ==========================================================================
136
- // STATE & REFS
137
- // ==========================================================================
138
- const predefinedPlaces = useMemo(() => predefinedPlacesProp || [], [
139
- predefinedPlacesProp,
140
- ]);
192
+ // --------------------------------------------------------------------------
193
+ // Derived values
194
+ // --------------------------------------------------------------------------
141
195
 
142
- // Store results array - useRef prevents re-renders when updating results, allows access to latest results in callbacks
143
- const resultsRef = useRef([]);
196
+ const url = useMemo(() => getRequestUrl(requestUrl), [requestUrl]);
197
+ const requestHeaders = requestUrl?.headers;
198
+ const withCredentials = url === GOOGLE_API_BASE;
144
199
 
145
- // Store active XMLHttpRequest objects - needed to abort requests when component unmounts or new search starts
146
- const requestsRef = useRef([]);
200
+ const isSupportedPlatform = !(Platform.OS === 'web' && !requestUrl);
147
201
 
148
- // Track if navigator warning has been shown - prevents duplicate console warnings
149
- const hasWarnedAboutNavigator = useRef(false);
202
+ const mergedStyles = useMemo(
203
+ () => mergeStyles(styles, suppressDefaultStyles),
204
+ [styles, suppressDefaultStyles],
205
+ );
150
206
 
151
- // Reference to TextInput component - enables imperative methods (focus, blur, clear) via ref
152
- const inputRef = useRef(null);
207
+ const isAutoMode =
208
+ listViewDisplayedProp === 'auto' || listViewDisplayedProp === undefined;
153
209
 
154
- // Store current query object - allows access to latest query in callbacks without stale closures
155
- const queryRef = useRef(query);
210
+ const hasGeolocation = currentLocation === true && !!getGeolocationProvider();
156
211
 
157
- // Store previous query string - used to detect query changes without causing re-renders
158
- const prevQueryStringRef = useRef(JSON.stringify(query));
212
+ const rowOptions = useMemo(
213
+ () => ({
214
+ predefinedPlaces,
215
+ predefinedPlacesAlwaysVisible,
216
+ currentLocation: hasGeolocation,
217
+ currentLocationLabel,
218
+ }),
219
+ [
220
+ predefinedPlaces,
221
+ predefinedPlacesAlwaysVisible,
222
+ hasGeolocation,
223
+ currentLocationLabel,
224
+ ],
225
+ );
159
226
 
160
- // Store latest _request function - ensures debounced function always calls current version with latest closures
161
- const requestRef = useRef(_request);
162
- const queryString = useMemo(() => JSON.stringify(query), [query]);
227
+ // --------------------------------------------------------------------------
228
+ // State & refs
229
+ // --------------------------------------------------------------------------
163
230
 
164
231
  const [stateText, setStateText] = useState('');
165
- const [dataSource, setDataSource] = useState([]);
166
- const [listViewDisplayed, setListViewDisplayed] = useState(
167
- listViewDisplayedProp === 'auto' ? false : listViewDisplayedProp,
168
- );
232
+ const [dataSource, setDataSource] = useState(EMPTY_ARRAY);
169
233
  const [listWasDismissed, setListWasDismissed] = useState(false);
170
- const [url, setUrl] = useState('');
171
234
  const [listLoaderDisplayed, setListLoaderDisplayed] = useState(false);
172
- const [sessionToken, setSessionToken] = useState(uuid.v4());
235
+ const [loadingRowKey, setLoadingRowKey] = useState(null);
236
+ const [sessionToken, setSessionToken] = useState(uuidv4);
173
237
 
174
- // ==========================================================================
175
- // UTILITY FUNCTIONS
176
- // ==========================================================================
238
+ const inputRef = useRef(null);
239
+ const resultsRef = useRef(EMPTY_ARRAY);
240
+ const requestsRef = useRef([]);
241
+ const detailsRequestRef = useRef(null);
242
+ const stateTextRef = useRef(stateText);
243
+ const loadingRowKeyRef = useRef(loadingRowKey);
244
+ const prevQueryStringRef = useRef(null);
245
+ // True from the moment a row is chosen until the user edits the text again.
246
+ // Focus alone must not re-open the list while it is set: the field already
247
+ // holds what the user picked, and the predictions behind it are spent.
248
+ const selectionMadeRef = useRef(false);
249
+ // The description of the row that was chosen. The platform writes it into
250
+ // the input and echoes it back through onChangeText, which is indisting-
251
+ // uishable from typing unless we remember what we are expecting.
252
+ const selectedTextRef = useRef(null);
253
+
254
+ stateTextRef.current = stateText;
255
+ loadingRowKeyRef.current = loadingRowKey;
177
256
 
178
- const hasNavigator = useCallback(() => {
179
- if (navigator?.geolocation) {
180
- return true;
181
- }
182
- if (!hasWarnedAboutNavigator.current) {
183
- if (Platform.OS === 'web') {
184
- console.warn(
185
- 'Geolocation is not available. For web, ensure your site is served over HTTPS or localhost to use geolocation features.',
186
- );
187
- } else {
188
- console.warn(
189
- 'Geolocation is not available. For React Native, you may need to install and configure @react-native-community/geolocation or expo-location to enable currentLocation.',
190
- );
191
- }
192
- hasWarnedAboutNavigator.current = true;
193
- }
194
- return false;
195
- }, []);
257
+ const queryString = useMemo(() => JSON.stringify(query), [query]);
196
258
 
197
- const supportedPlatform = () => {
198
- if (Platform.OS === 'web' && !props.requestUrl) {
199
- console.warn(
200
- 'This library cannot be used for the web unless you specify the requestUrl prop. See https://git.io/JflFv for more for details.',
201
- );
202
- return false;
203
- }
204
- return true;
205
- };
259
+ // --------------------------------------------------------------------------
260
+ // Helpers
261
+ //
262
+ // Declaration order below is load-bearing: every one of these is read by the
263
+ // ones that follow it. Reading a `const` above its declaration is a temporal
264
+ // dead zone error under any bundler that does not downlevel `const` to `var`
265
+ // (Expo web, Vite, webpack with modern targets).
266
+ // --------------------------------------------------------------------------
267
+
268
+ // Searches and the Place Details lookup are tracked separately because their
269
+ // lifetimes are independent: a details request is started by a row press and
270
+ // outlives the search that produced the row. Sharing one list meant the next
271
+ // search aborted the details request that had just been sent, and since the
272
+ // abort detaches the handler the selection was lost without a single
273
+ // callback firing (#998).
274
+ const abortSearchRequests = useCallback(() => {
275
+ requestsRef.current.forEach(abortRequest);
276
+ requestsRef.current = [];
277
+ }, []);
206
278
 
207
- const getRequestUrl = (requestUrl) => {
208
- if (requestUrl) {
209
- if (requestUrl.useOnPlatform === 'all') {
210
- return requestUrl.url;
211
- }
212
- if (requestUrl.useOnPlatform === 'web') {
213
- return Platform.select({
214
- web: requestUrl.url,
215
- default: 'https://maps.googleapis.com/maps/api',
216
- });
217
- }
279
+ const abortDetailsRequest = useCallback(() => {
280
+ if (detailsRequestRef.current) {
281
+ abortRequest(detailsRequestRef.current);
282
+ detailsRequestRef.current = null;
218
283
  }
219
- return 'https://maps.googleapis.com/maps/api';
220
- };
221
-
222
- const getRequestHeaders = (requestUrl) => {
223
- return requestUrl?.headers || {};
224
- };
284
+ }, []);
225
285
 
226
- const setRequestHeaders = (request, headers) => {
227
- Object.keys(headers).forEach((headerKey) =>
228
- request.setRequestHeader(headerKey, headers[headerKey]),
229
- );
230
- };
286
+ const prepareRequest = useCallback(
287
+ (request) => {
288
+ request.timeout = timeout;
289
+ request.ontimeout = onTimeout;
290
+ return request;
291
+ },
292
+ [timeout, onTimeout],
293
+ );
231
294
 
232
- const requestShouldUseWithCredentials = useCallback(() => {
233
- return url === 'https://maps.googleapis.com/maps/api';
234
- }, [url]);
295
+ const trackSearchRequest = useCallback(
296
+ (request) => {
297
+ requestsRef.current.push(prepareRequest(request));
298
+ return request;
299
+ },
300
+ [prepareRequest],
301
+ );
235
302
 
236
- const _abortRequests = useCallback(() => {
237
- requestsRef.current.forEach((request) => {
238
- request.onreadystatechange = null;
239
- request.abort();
240
- });
241
- requestsRef.current = [];
242
- }, []);
303
+ const trackDetailsRequest = useCallback(
304
+ (request) => {
305
+ detailsRequestRef.current = prepareRequest(request);
306
+ return request;
307
+ },
308
+ [prepareRequest],
309
+ );
243
310
 
244
- // ==========================================================================
245
- // DATA PROCESSING FUNCTIONS
246
- // ==========================================================================
311
+ const buildRows = useCallback(
312
+ (results, text) => buildRowsFromResults(results, text, rowOptions),
313
+ [rowOptions],
314
+ );
247
315
 
248
- const buildRowsFromResults = useCallback(
249
- (results, text) => {
250
- let res = [];
251
- // Show predefined places if:
252
- // 1. No text entered and no results, OR
253
- // 2. predefinedPlacesAlwaysVisible is true
254
- const shouldDisplayPredefinedPlaces =
255
- (!text || text.length === 0) && results.length === 0;
256
- if (
257
- shouldDisplayPredefinedPlaces ||
258
- predefinedPlacesAlwaysVisible === true
259
- ) {
260
- if (predefinedPlaces.length > 0) {
261
- res = [
262
- ...predefinedPlaces.filter((place) => place?.description?.length),
263
- ];
264
- }
316
+ const disableRowLoaders = useCallback(() => setLoadingRowKey(null), []);
265
317
 
266
- if (currentLocation === true && hasNavigator()) {
267
- res.unshift({
268
- description: currentLocationLabel,
269
- isCurrentLocation: true,
270
- });
271
- }
318
+ const reportFailure = useCallback(
319
+ (message) => {
320
+ if (onFail) {
321
+ onFail(message);
322
+ } else {
323
+ console.warn('google places autocomplete: ' + message);
272
324
  }
273
-
274
- res = res.map((place) => ({
275
- ...place,
276
- isPredefinedPlace: true,
277
- }));
278
-
279
- return [...res, ...results];
280
325
  },
281
- [
282
- predefinedPlacesAlwaysVisible,
283
- predefinedPlaces,
284
- currentLocation,
285
- currentLocationLabel,
286
- hasNavigator,
287
- ],
326
+ [onFail],
288
327
  );
289
328
 
290
- const _filterResultsByTypes = useCallback((unfilteredResults, types) => {
291
- if (!types || types.length === 0) return unfilteredResults;
292
-
293
- const results = [];
294
- for (let i = 0; i < unfilteredResults.length; i++) {
295
- let found = false;
296
-
297
- for (let j = 0; j < types.length; j++) {
298
- if (unfilteredResults[i].types?.indexOf(types[j]) !== -1) {
299
- found = true;
300
- break;
301
- }
329
+ const renderRowDescription = useCallback(
330
+ (rowData) => {
331
+ if (renderDescription) {
332
+ return renderDescription(rowData);
302
333
  }
334
+ return rowData.description || rowData.formatted_address || rowData.name;
335
+ },
336
+ [renderDescription],
337
+ );
303
338
 
304
- if (found === true) {
305
- results.push(unfilteredResults[i]);
339
+ const getPredefinedPlace = useCallback(
340
+ (rowData) => {
341
+ if (rowData.isPredefinedPlace !== true) {
342
+ return rowData;
306
343
  }
307
- }
308
- return results;
309
- }, []);
344
+ const match = predefinedPlaces.find(
345
+ (place) => place?.description === rowData.description,
346
+ );
347
+ return match || rowData;
348
+ },
349
+ [predefinedPlaces],
350
+ );
310
351
 
311
- const _filterResultsByPlacePredictions = (unfilteredResults) => {
312
- const results = [];
313
- for (let i = 0; i < unfilteredResults.length; i++) {
314
- if (unfilteredResults[i].placePrediction) {
315
- results.push({
316
- description: unfilteredResults[i].placePrediction.text?.text,
317
- place_id: unfilteredResults[i].placePrediction.placeId,
318
- reference: unfilteredResults[i].placePrediction.placeId,
319
- structured_formatting: {
320
- main_text:
321
- unfilteredResults[i].placePrediction.structuredFormat?.mainText
322
- ?.text,
323
- secondary_text:
324
- unfilteredResults[i].placePrediction.structuredFormat
325
- ?.secondaryText?.text,
326
- },
327
- types: unfilteredResults[i].placePrediction.types ?? [],
328
- });
352
+ const showResults = useCallback(
353
+ (results, text) => {
354
+ resultsRef.current = results;
355
+ setDataSource(buildRows(results, text));
356
+
357
+ // A response that lands after the user has already chosen a row is
358
+ // stale by definition: showing it would re-open the list over the
359
+ // selection.
360
+ if (!selectionMadeRef.current) {
361
+ setListWasDismissed(false);
329
362
  }
330
- }
331
- return results;
332
- };
363
+ },
364
+ [buildRows],
365
+ );
333
366
 
334
- const _getPredefinedPlace = (rowData) => {
335
- if (rowData.isPredefinedPlace !== true) {
336
- return rowData;
337
- }
367
+ // --------------------------------------------------------------------------
368
+ // Requests
369
+ // --------------------------------------------------------------------------
338
370
 
339
- if (predefinedPlaces.length > 0) {
340
- for (let i = 0; i < predefinedPlaces.length; i++) {
341
- if (predefinedPlaces[i].description === rowData.description) {
342
- return predefinedPlaces[i];
343
- }
371
+ const requestNearby = useCallback(
372
+ (latitude, longitude) => {
373
+ abortSearchRequests();
374
+
375
+ if (latitude == null || longitude == null) {
376
+ resultsRef.current = EMPTY_ARRAY;
377
+ setDataSource(buildRows(EMPTY_ARRAY, ''));
378
+ return;
344
379
  }
345
- }
346
380
 
347
- return rowData;
348
- };
381
+ const request = trackSearchRequest(new XMLHttpRequest());
349
382
 
350
- // ==========================================================================
351
- // API REQUEST FUNCTIONS
352
- // ==========================================================================
383
+ request.onreadystatechange = () => {
384
+ if (request.readyState !== 4) {
385
+ setListLoaderDisplayed(true);
386
+ return;
387
+ }
353
388
 
354
- const _requestNearby = useCallback(
355
- (latitude, longitude) => {
356
- _abortRequests();
357
-
358
- if (
359
- latitude !== undefined &&
360
- longitude !== undefined &&
361
- latitude !== null &&
362
- longitude !== null
363
- ) {
364
- const request = new XMLHttpRequest();
365
- requestsRef.current.push(request);
366
- request.timeout = timeout;
367
- request.ontimeout = onTimeout;
368
- request.onreadystatechange = () => {
369
- if (request.readyState !== 4) {
370
- setListLoaderDisplayed(true);
371
- return;
372
- }
389
+ setListLoaderDisplayed(false);
373
390
 
374
- setListLoaderDisplayed(false);
375
- if (request.status === 200) {
376
- const responseJSON = JSON.parse(request.responseText);
391
+ if (request.status !== 200) {
392
+ return;
393
+ }
377
394
 
378
- _disableRowLoaders();
395
+ const responseJSON = JSON.parse(request.responseText);
396
+ disableRowLoaders();
379
397
 
380
- if (typeof responseJSON.results !== 'undefined') {
381
- let results = [];
382
- if (nearbyPlacesAPI === 'GoogleReverseGeocoding') {
383
- results = _filterResultsByTypes(
398
+ if (typeof responseJSON.results !== 'undefined') {
399
+ const results =
400
+ nearbyPlacesAPI === 'GoogleReverseGeocoding'
401
+ ? filterResultsByTypes(
384
402
  responseJSON.results,
385
403
  filterReverseGeocodingByTypes,
386
- );
387
- } else {
388
- results = responseJSON.results;
389
- }
390
-
391
- resultsRef.current = results;
392
- const newDataSource = buildRowsFromResults(results);
393
- setDataSource(newDataSource);
394
- // Auto-show list when results arrive if in 'auto' mode
395
- if (
396
- listViewDisplayedProp === 'auto' &&
397
- newDataSource.length > 0
398
- ) {
399
- setListWasDismissed(false);
400
- setListViewDisplayed(true);
401
- }
402
- }
403
- if (typeof responseJSON.error_message !== 'undefined') {
404
- if (!onFail) {
405
- console.warn(
406
- 'google places autocomplete: ' + responseJSON.error_message,
407
- );
408
- } else {
409
- onFail(responseJSON.error_message);
410
- }
411
- }
412
- }
413
- };
414
-
415
- let requestUrl = '';
416
- if (nearbyPlacesAPI === 'GoogleReverseGeocoding') {
417
- // your key must be allowed to use Google Maps Geocoding API
418
- requestUrl =
419
- `${url}/geocode/json?` +
420
- Qs.stringify({
421
- latlng: latitude + ',' + longitude,
404
+ )
405
+ : responseJSON.results;
406
+
407
+ showResults(results, '');
408
+ }
409
+
410
+ if (typeof responseJSON.error_message !== 'undefined') {
411
+ reportFailure(responseJSON.error_message);
412
+ }
413
+ };
414
+
415
+ const requestUrlPath =
416
+ nearbyPlacesAPI === 'GoogleReverseGeocoding'
417
+ ? `${url}/geocode/json?` +
418
+ stringify({
419
+ latlng: `${latitude},${longitude}`,
422
420
  key: query.key,
423
421
  ...GoogleReverseGeocodingQuery,
424
- });
425
- } else {
426
- requestUrl =
427
- `${url}/place/nearbysearch/json?` +
428
- Qs.stringify({
429
- location: latitude + ',' + longitude,
422
+ })
423
+ : `${url}/place/nearbysearch/json?` +
424
+ stringify({
425
+ location: `${latitude},${longitude}`,
430
426
  key: query.key,
431
427
  ...GooglePlacesSearchQuery,
432
428
  });
433
- }
434
-
435
- request.open('GET', requestUrl);
436
429
 
437
- request.withCredentials = requestShouldUseWithCredentials();
438
- setRequestHeaders(request, getRequestHeaders(props.requestUrl));
439
-
440
- request.send();
441
- } else {
442
- resultsRef.current = [];
443
- setDataSource(buildRowsFromResults([]));
444
- }
430
+ request.open('GET', requestUrlPath);
431
+ request.withCredentials = withCredentials;
432
+ setRequestHeaders(request, requestHeaders);
433
+ request.send();
445
434
  },
446
435
  [
447
- _abortRequests,
448
- timeout,
449
- onTimeout,
450
- _disableRowLoaders,
436
+ abortSearchRequests,
437
+ trackSearchRequest,
438
+ buildRows,
439
+ disableRowLoaders,
440
+ showResults,
441
+ reportFailure,
451
442
  nearbyPlacesAPI,
452
- _filterResultsByTypes,
453
443
  filterReverseGeocodingByTypes,
454
- buildRowsFromResults,
455
- listViewDisplayedProp,
456
- onFail,
457
444
  url,
458
- query,
445
+ query.key,
459
446
  GoogleReverseGeocodingQuery,
460
447
  GooglePlacesSearchQuery,
461
- requestShouldUseWithCredentials,
462
- props.requestUrl,
448
+ withCredentials,
449
+ requestHeaders,
463
450
  ],
464
451
  );
465
452
 
466
- const _request = (text) => {
467
- _abortRequests();
453
+ const request = useCallback(
454
+ (text) => {
455
+ abortSearchRequests();
468
456
 
469
- if (!url) {
470
- return;
471
- }
457
+ if (!isSupportedPlatform) {
458
+ return;
459
+ }
472
460
 
473
- if (supportedPlatform() && text && text.length >= minLength) {
474
- const request = new XMLHttpRequest();
475
- requestsRef.current.push(request);
461
+ if (!text || text.length < minLength) {
462
+ resultsRef.current = EMPTY_ARRAY;
463
+ setDataSource(buildRows(EMPTY_ARRAY, ''));
464
+ return;
465
+ }
476
466
 
477
- request.timeout = timeout;
478
- request.ontimeout = onTimeout;
479
- request.onreadystatechange = () => {
480
- if (request.readyState !== 4) {
467
+ const httpRequest = trackSearchRequest(new XMLHttpRequest());
468
+
469
+ httpRequest.onreadystatechange = () => {
470
+ if (httpRequest.readyState !== 4) {
481
471
  setListLoaderDisplayed(true);
482
472
  return;
483
473
  }
484
474
 
485
475
  setListLoaderDisplayed(false);
486
476
 
487
- if (request.status === 200) {
488
- const responseJSON = JSON.parse(request.responseText);
489
-
490
- if (typeof responseJSON.predictions !== 'undefined') {
491
- const results =
492
- nearbyPlacesAPI === 'GoogleReverseGeocoding'
493
- ? _filterResultsByTypes(
494
- responseJSON.predictions,
495
- filterReverseGeocodingByTypes,
496
- )
497
- : responseJSON.predictions;
498
-
499
- resultsRef.current = results;
500
- const newDataSource = buildRowsFromResults(results, text);
501
- setDataSource(newDataSource);
502
- // Auto-show list when results arrive if in 'auto' mode
503
- if (listViewDisplayedProp === 'auto' && newDataSource.length > 0) {
504
- setListWasDismissed(false);
505
- setListViewDisplayed(true);
506
- }
507
- }
508
- if (typeof responseJSON.suggestions !== 'undefined') {
509
- const results = _filterResultsByPlacePredictions(
510
- responseJSON.suggestions,
511
- );
512
-
513
- resultsRef.current = results;
514
- const newDataSource = buildRowsFromResults(results, text);
515
- setDataSource(newDataSource);
516
- // Auto-show list when results arrive if in 'auto' mode
517
- if (listViewDisplayedProp === 'auto' && newDataSource.length > 0) {
518
- setListWasDismissed(false);
519
- setListViewDisplayed(true);
520
- }
521
- }
522
- if (typeof responseJSON.error_message !== 'undefined') {
523
- if (!onFail) {
524
- console.warn(
525
- 'google places autocomplete: ' + responseJSON.error_message,
526
- );
527
- } else {
528
- onFail(responseJSON.error_message);
529
- }
530
- }
531
- } else {
532
- console.warn(
533
- 'google places autocomplete: request could not be completed or has been aborted',
477
+ if (httpRequest.status !== 200) {
478
+ reportFailure('request could not be completed or has been aborted');
479
+ return;
480
+ }
481
+
482
+ const responseJSON = JSON.parse(httpRequest.responseText);
483
+
484
+ if (typeof responseJSON.predictions !== 'undefined') {
485
+ const results =
486
+ nearbyPlacesAPI === 'GoogleReverseGeocoding'
487
+ ? filterResultsByTypes(
488
+ responseJSON.predictions,
489
+ filterReverseGeocodingByTypes,
490
+ )
491
+ : responseJSON.predictions;
492
+
493
+ showResults(results, text);
494
+ }
495
+
496
+ if (typeof responseJSON.suggestions !== 'undefined') {
497
+ showResults(
498
+ filterResultsByPlacePredictions(responseJSON.suggestions),
499
+ text,
534
500
  );
535
501
  }
502
+
503
+ if (typeof responseJSON.error_message !== 'undefined') {
504
+ reportFailure(responseJSON.error_message);
505
+ }
536
506
  };
537
507
 
538
- if (props.preProcess) {
539
- setStateText(props.preProcess(text));
508
+ if (preProcess) {
509
+ setStateText(preProcess(text));
540
510
  }
541
511
 
542
512
  if (isNewPlacesAPI) {
543
513
  const keyQueryParam = query.key
544
- ? '?' +
545
- Qs.stringify({
546
- key: query.key,
547
- })
514
+ ? '?' + stringify({ key: query.key })
548
515
  : '';
549
- request.open('POST', `${url}/v1/places:autocomplete${keyQueryParam}`);
516
+ httpRequest.open(
517
+ 'POST',
518
+ `${url}/v1/places:autocomplete${keyQueryParam}`,
519
+ );
550
520
  } else {
551
- request.open(
521
+ httpRequest.open(
552
522
  'GET',
553
523
  `${url}/place/autocomplete/json?input=` +
554
524
  encodeURIComponent(text) +
555
525
  '&' +
556
- Qs.stringify(query),
526
+ stringify(query),
557
527
  );
558
528
  }
559
529
 
560
- request.withCredentials = requestShouldUseWithCredentials();
561
- setRequestHeaders(request, getRequestHeaders(props.requestUrl));
530
+ httpRequest.withCredentials = withCredentials;
531
+ setRequestHeaders(httpRequest, requestHeaders);
562
532
 
563
533
  if (isNewPlacesAPI) {
564
- const { key, locationbias, types, ...rest } = query;
565
- request.send(
566
- JSON.stringify({
567
- input: text,
568
- sessionToken,
569
- ...rest,
570
- }),
571
- );
534
+ // v1 renamed/removed several legacy parameters. `language` in
535
+ // particular is rejected outright — it is `languageCode` now.
536
+ const { key, locationbias, types, language, ...rest } = query;
537
+ const body = { input: text, sessionToken, ...rest };
538
+
539
+ if (language) {
540
+ body.languageCode = language;
541
+ }
542
+
543
+ httpRequest.send(JSON.stringify(body));
572
544
  } else {
573
- request.send();
545
+ httpRequest.send();
574
546
  }
575
- } else {
576
- resultsRef.current = [];
577
- setDataSource(buildRowsFromResults([]));
578
- }
579
- };
547
+ },
548
+ [
549
+ abortSearchRequests,
550
+ trackSearchRequest,
551
+ buildRows,
552
+ showResults,
553
+ reportFailure,
554
+ isSupportedPlatform,
555
+ minLength,
556
+ nearbyPlacesAPI,
557
+ filterReverseGeocodingByTypes,
558
+ preProcess,
559
+ isNewPlacesAPI,
560
+ url,
561
+ query,
562
+ sessionToken,
563
+ withCredentials,
564
+ requestHeaders,
565
+ ],
566
+ );
567
+
568
+ // The debounced caller reads through a ref so it always invokes the current
569
+ // closure without having to be rebuilt (and reset) on every render.
570
+ const requestRef = useRef(null);
571
+ useEffect(() => {
572
+ requestRef.current = request;
573
+ }, [request]);
574
+
575
+ const debouncedRequest = useMemo(
576
+ () => createDebounce((text) => requestRef.current?.(text), debounceMs),
577
+ [debounceMs],
578
+ );
580
579
 
581
580
  const getCurrentLocation = useCallback(() => {
582
- let options = {
583
- enableHighAccuracy: false,
584
- timeout: 20000,
585
- maximumAge: 1000,
586
- };
587
-
588
- if (enableHighAccuracyLocation && Platform.OS === 'android') {
589
- options = {
590
- enableHighAccuracy: true,
591
- timeout: 20000,
592
- };
581
+ const provider = getGeolocationProvider();
582
+
583
+ if (!provider) {
584
+ return;
593
585
  }
594
- const getCurrentPosition =
595
- navigator.geolocation.getCurrentPosition ||
596
- navigator.geolocation.default?.getCurrentPosition;
597
-
598
- if (getCurrentPosition) {
599
- getCurrentPosition(
600
- (position) => {
601
- if (nearbyPlacesAPI === 'None') {
602
- const currentLocationData = {
603
- description: currentLocationLabel,
604
- geometry: {
605
- location: {
606
- lat: position.coords.latitude,
607
- lng: position.coords.longitude,
608
- },
586
+
587
+ const options =
588
+ enableHighAccuracyLocation && Platform.OS === 'android'
589
+ ? { enableHighAccuracy: true, timeout: 20000 }
590
+ : { enableHighAccuracy: false, timeout: 20000, maximumAge: 1000 };
591
+
592
+ provider.getCurrentPosition(
593
+ (position) => {
594
+ if (nearbyPlacesAPI === 'None') {
595
+ const currentLocationData = {
596
+ description: currentLocationLabel,
597
+ geometry: {
598
+ location: {
599
+ lat: position.coords.latitude,
600
+ lng: position.coords.longitude,
609
601
  },
610
- };
602
+ },
603
+ };
611
604
 
612
- _disableRowLoaders();
613
- onPress(currentLocationData, currentLocationData);
614
- } else {
615
- _requestNearby(position.coords.latitude, position.coords.longitude);
616
- }
617
- },
618
- (error) => {
619
- _disableRowLoaders();
620
- console.error(error.message);
621
- },
622
- options,
623
- );
624
- }
605
+ disableRowLoaders();
606
+ onPressProp(currentLocationData, currentLocationData);
607
+ return;
608
+ }
609
+
610
+ requestNearby(position.coords.latitude, position.coords.longitude);
611
+ },
612
+ (error) => {
613
+ disableRowLoaders();
614
+ console.error(error.message);
615
+ },
616
+ options,
617
+ );
625
618
  }, [
626
619
  enableHighAccuracyLocation,
627
620
  currentLocationLabel,
628
621
  nearbyPlacesAPI,
629
- _disableRowLoaders,
630
- onPress,
631
- _requestNearby,
622
+ disableRowLoaders,
623
+ onPressProp,
624
+ requestNearby,
632
625
  ]);
633
626
 
634
- // ==========================================================================
635
- // EVENT HANDLERS
636
- // ==========================================================================
637
-
638
- const _enableRowLoader = (rowData) => {
639
- const rows = buildRowsFromResults(resultsRef.current);
640
- for (let i = 0; i < rows.length; i++) {
641
- if (
642
- rows[i].place_id === rowData.place_id ||
643
- (rows[i].isCurrentLocation === true &&
644
- rowData.isCurrentLocation === true)
645
- ) {
646
- rows[i].isLoading = true;
647
- setDataSource(rows);
648
- break;
649
- }
650
- }
651
- };
627
+ // --------------------------------------------------------------------------
628
+ // Handlers
629
+ // --------------------------------------------------------------------------
652
630
 
653
- const _disableRowLoaders = useCallback(() => {
654
- for (let i = 0; i < resultsRef.current.length; i++) {
655
- if (resultsRef.current[i].isLoading === true) {
656
- resultsRef.current[i].isLoading = false;
631
+ const hideListView = useCallback(
632
+ (force = false) => {
633
+ if (!keepResultsAfterBlur || force) {
634
+ setListWasDismissed(true);
657
635
  }
658
- }
659
-
660
- setDataSource(buildRowsFromResults(resultsRef.current));
661
- }, [buildRowsFromResults]);
636
+ },
637
+ [keepResultsAfterBlur],
638
+ );
662
639
 
663
- const _onPress = (rowData) => {
664
- if (rowData.isPredefinedPlace !== true && fetchDetails === true) {
665
- if (rowData.isLoading === true) {
666
- // already requesting
640
+ const handleBlur = useCallback(
641
+ (event) => {
642
+ if (event && isFocusInsideResultList(event)) {
667
643
  return;
668
644
  }
645
+ hideListView();
646
+ inputRef.current?.blur();
647
+ },
648
+ [hideListView],
649
+ );
669
650
 
651
+ const fetchPlaceDetails = useCallback(
652
+ (rowData) => {
670
653
  hideListView(true);
671
654
  Keyboard.dismiss();
655
+ abortSearchRequests();
656
+ abortDetailsRequest();
657
+ setLoadingRowKey(getRowKey(rowData));
672
658
 
673
- _abortRequests();
659
+ const detailsRequest = trackDetailsRequest(new XMLHttpRequest());
674
660
 
675
- // display loader
676
- _enableRowLoader(rowData);
661
+ detailsRequest.onreadystatechange = () => {
662
+ if (detailsRequest.readyState !== 4) {
663
+ return;
664
+ }
677
665
 
678
- // fetch details
679
- const request = new XMLHttpRequest();
680
- requestsRef.current.push(request);
681
- request.timeout = timeout;
682
- request.ontimeout = onTimeout;
683
- request.onreadystatechange = () => {
684
- if (request.readyState !== 4) return;
685
-
686
- if (request.status === 200) {
687
- const responseJSON = JSON.parse(request.responseText);
688
- if (
689
- responseJSON.status === 'OK' ||
690
- (isNewPlacesAPI && responseJSON.id)
691
- ) {
692
- const details = isNewPlacesAPI ? responseJSON : responseJSON.result;
693
- _disableRowLoaders();
694
- _onBlur();
695
-
696
- setStateText(_renderDescription(rowData));
697
-
698
- delete rowData.isLoading;
699
- onPress(rowData, details);
700
- } else {
701
- _disableRowLoaders();
702
-
703
- if (autoFillOnNotFound) {
704
- setStateText(_renderDescription(rowData));
705
- delete rowData.isLoading;
706
- }
707
-
708
- if (!onNotFound) {
709
- console.warn(
710
- 'google places autocomplete: ' + responseJSON.status,
711
- );
712
- } else {
713
- onNotFound(responseJSON);
714
- }
715
- }
666
+ // Whatever the outcome, this request is no longer in flight: releasing
667
+ // it here keeps a later abort from reaching a settled request.
668
+ if (detailsRequestRef.current === detailsRequest) {
669
+ detailsRequestRef.current = null;
670
+ }
671
+
672
+ if (detailsRequest.status !== 200) {
673
+ disableRowLoaders();
674
+ reportFailure('request could not be completed or has been aborted');
675
+ return;
676
+ }
677
+
678
+ const responseJSON = JSON.parse(detailsRequest.responseText);
679
+ const succeeded =
680
+ responseJSON.status === 'OK' || (isNewPlacesAPI && responseJSON.id);
681
+
682
+ disableRowLoaders();
683
+
684
+ if (succeeded) {
685
+ const details = isNewPlacesAPI ? responseJSON : responseJSON.result;
686
+ handleBlur();
687
+ setStateText(renderRowDescription(rowData));
688
+ onPressProp(rowData, details);
689
+ return;
690
+ }
691
+
692
+ if (autoFillOnNotFound) {
693
+ setStateText(renderRowDescription(rowData));
694
+ }
695
+
696
+ if (onNotFound) {
697
+ onNotFound(responseJSON);
716
698
  } else {
717
- _disableRowLoaders();
718
-
719
- if (!onFail) {
720
- console.warn(
721
- 'google places autocomplete: request could not be completed or has been aborted',
722
- );
723
- } else {
724
- onFail('request could not be completed or has been aborted');
725
- }
699
+ console.warn('google places autocomplete: ' + responseJSON.status);
726
700
  }
727
701
  };
728
702
 
729
703
  if (isNewPlacesAPI) {
730
- request.open(
704
+ detailsRequest.open(
731
705
  'GET',
732
706
  `${url}/v1/places/${rowData.place_id}?` +
733
- Qs.stringify({
734
- key: query.key,
735
- sessionToken,
736
- fields,
737
- }),
707
+ stringify({ key: query.key, sessionToken, fields }),
738
708
  );
739
- setSessionToken(uuid.v4());
709
+ setSessionToken(uuidv4());
740
710
  } else {
741
- request.open(
711
+ detailsRequest.open(
742
712
  'GET',
743
713
  `${url}/place/details/json?` +
744
- Qs.stringify({
714
+ stringify({
745
715
  key: query.key,
746
716
  placeid: rowData.place_id,
747
717
  language: query.language,
@@ -750,465 +720,351 @@ export const GooglePlacesAutocomplete = forwardRef((props, ref) => {
750
720
  );
751
721
  }
752
722
 
753
- request.withCredentials = requestShouldUseWithCredentials();
754
- setRequestHeaders(request, getRequestHeaders(props.requestUrl));
723
+ detailsRequest.withCredentials = withCredentials;
724
+ setRequestHeaders(detailsRequest, requestHeaders);
725
+ detailsRequest.send();
726
+ },
727
+ [
728
+ hideListView,
729
+ abortSearchRequests,
730
+ abortDetailsRequest,
731
+ trackDetailsRequest,
732
+ disableRowLoaders,
733
+ reportFailure,
734
+ handleBlur,
735
+ renderRowDescription,
736
+ onPressProp,
737
+ onNotFound,
738
+ autoFillOnNotFound,
739
+ isNewPlacesAPI,
740
+ url,
741
+ query.key,
742
+ query.language,
743
+ sessionToken,
744
+ fields,
745
+ GooglePlacesDetailsQuery,
746
+ withCredentials,
747
+ requestHeaders,
748
+ ],
749
+ );
755
750
 
756
- request.send();
757
- } else if (rowData.isCurrentLocation === true) {
758
- hideListView(true);
759
- // display loader
760
- _enableRowLoader(rowData);
751
+ const handleRowPress = useCallback(
752
+ (rowData) => {
753
+ // Selecting a row ends the search. The keystroke that armed this timer is
754
+ // already answered by the row the user just tapped, so firing it would
755
+ // only bill another autocomplete call and repaint a dismissed list. A
756
+ // search already on the wire would do the same through showResults(),
757
+ // so it goes too.
758
+ debouncedRequest.cancel();
759
+ abortSearchRequests();
760
+ selectionMadeRef.current = true;
761
+ selectedTextRef.current = renderRowDescription(rowData);
762
+
763
+ if (rowData.isCurrentLocation === true) {
764
+ // Current location is not a terminal choice when it feeds a nearby
765
+ // search: those results are meant to open the list again.
766
+ selectionMadeRef.current = false;
767
+ hideListView(true);
768
+ setLoadingRowKey(getRowKey(rowData));
769
+ setStateText(renderRowDescription(rowData));
770
+ getCurrentLocation();
771
+ return;
772
+ }
761
773
 
762
- setStateText(_renderDescription(rowData));
774
+ if (rowData.isPredefinedPlace !== true && fetchDetails === true) {
775
+ if (loadingRowKeyRef.current === getRowKey(rowData)) {
776
+ return; // already requesting
777
+ }
778
+ fetchPlaceDetails(rowData);
779
+ return;
780
+ }
763
781
 
764
- delete rowData.isLoading;
765
- getCurrentLocation();
766
- } else {
767
782
  hideListView(true);
768
- setStateText(_renderDescription(rowData));
769
-
770
- _onBlur();
771
- delete rowData.isLoading;
772
- const predefinedPlace = _getPredefinedPlace(rowData);
783
+ setStateText(renderRowDescription(rowData));
784
+ handleBlur();
773
785
 
774
- // sending predefinedPlace as details for predefined places
775
- onPress(predefinedPlace, predefinedPlace);
776
- }
777
- };
778
-
779
- const _onChangeText = (text) => {
780
- setListWasDismissed(false);
781
- setStateText(text);
782
- debounceData(text);
783
- };
786
+ // Predefined places are handed back as their own details payload.
787
+ const predefinedPlace = getPredefinedPlace(rowData);
788
+ onPressProp(predefinedPlace, predefinedPlace);
789
+ },
790
+ [
791
+ debouncedRequest,
792
+ abortSearchRequests,
793
+ hideListView,
794
+ renderRowDescription,
795
+ getCurrentLocation,
796
+ fetchDetails,
797
+ fetchPlaceDetails,
798
+ handleBlur,
799
+ getPredefinedPlace,
800
+ onPressProp,
801
+ ],
802
+ );
784
803
 
785
- const _handleChangeText = (text) => {
786
- _onChangeText(text);
804
+ const handleChangeText = useCallback(
805
+ (text) => {
806
+ // The platform writes the chosen description into the input and echoes
807
+ // it straight back here. That is our own value returning, not the user
808
+ // editing, so it must not re-open the list or start another search.
809
+ if (selectionMadeRef.current && text === selectedTextRef.current) {
810
+ textInputProps?.onChangeText?.(text);
811
+ return;
812
+ }
787
813
 
788
- const onChangeText = textInputProps?.onChangeText;
814
+ // Editing the text supersedes whatever was selected before.
815
+ selectionMadeRef.current = false;
816
+ selectedTextRef.current = null;
817
+ setListWasDismissed(false);
818
+ setStateText(text);
819
+
820
+ // Text that cannot produce a search clears the results now instead of a
821
+ // debounce later. Deferring it left the previous predictions on screen —
822
+ // and un-dismissed the list to show them — until the timer elapsed, so
823
+ // pressing the clear button flashed the old list back for `debounce` ms.
824
+ if (!text || text.length < minLength) {
825
+ debouncedRequest.cancel();
826
+ request(text);
827
+ } else {
828
+ debouncedRequest(text);
829
+ }
789
830
 
790
- if (onChangeText) {
791
- onChangeText(text);
792
- }
793
- };
831
+ textInputProps?.onChangeText?.(text);
832
+ },
833
+ [debouncedRequest, request, minLength, textInputProps],
834
+ );
794
835
 
795
- const hideListView = useCallback(
796
- (force = false) => {
797
- if (!keepResultsAfterBlur || force) {
798
- setListWasDismissed(true);
799
- setListViewDisplayed(false);
836
+ const handleFocus = useCallback(
837
+ (event) => {
838
+ if (!selectionMadeRef.current) {
839
+ setListWasDismissed(false);
800
840
  }
841
+ textInputProps?.onFocus?.(event);
801
842
  },
802
- [keepResultsAfterBlur],
843
+ [textInputProps],
803
844
  );
804
845
 
805
- const isNewFocusInAutocompleteResultList = ({
806
- relatedTarget,
807
- currentTarget,
808
- }) => {
809
- if (!relatedTarget) return false;
810
-
811
- let node = relatedTarget.parentNode;
812
-
813
- while (node) {
814
- if (node.id === 'result-list-id') return true;
815
- node = node.parentNode;
816
- }
817
-
818
- return false;
819
- };
820
-
821
- const _onBlur = (e) => {
822
- if (e && isNewFocusInAutocompleteResultList(e)) return;
823
-
824
- hideListView();
825
- inputRef?.current?.blur();
826
- };
827
-
828
- const _onFocus = () => {
829
- setListWasDismissed(false);
830
- setListViewDisplayed(true);
831
- };
846
+ const handleInputBlur = useCallback(
847
+ (event) => {
848
+ handleBlur(event);
849
+ textInputProps?.onBlur?.(event);
850
+ },
851
+ [handleBlur, textInputProps],
852
+ );
832
853
 
833
- // ==========================================================================
834
- // RENDER FUNCTIONS
835
- // ==========================================================================
854
+ // --------------------------------------------------------------------------
855
+ // Effects
856
+ // --------------------------------------------------------------------------
836
857
 
837
- const _renderDescription = (rowData) => {
838
- if (props.renderDescription) {
839
- return props.renderDescription(rowData);
840
- }
841
-
842
- return rowData.description || rowData.formatted_address || rowData.name;
843
- };
844
-
845
- const _getRowLoader = () => {
846
- return <ActivityIndicator animating={true} size='small' />;
847
- };
848
-
849
- const _renderLoader = (rowData) => {
850
- if (rowData.isLoading === true) {
851
- return (
852
- <View
853
- style={[
854
- suppressDefaultStyles ? {} : defaultStyles.loader,
855
- styles?.loader,
856
- ]}
857
- >
858
- {_getRowLoader()}
859
- </View>
858
+ useEffect(() => {
859
+ if (Platform.OS === 'web' && !requestUrl) {
860
+ console.warn(
861
+ 'This library cannot be used for the web unless you specify the requestUrl prop. See https://git.io/JflFv for more for details.',
860
862
  );
861
863
  }
864
+ }, [requestUrl]);
862
865
 
863
- return null;
864
- };
865
-
866
- const _renderRowData = (rowData, index) => {
867
- if (props.renderRow) {
868
- return props.renderRow(rowData, index);
869
- }
870
-
871
- return (
872
- <Text
873
- style={[
874
- suppressDefaultStyles ? {} : defaultStyles.description,
875
- styles?.description,
876
- rowData.isPredefinedPlace ? styles?.predefinedPlacesDescription : {},
877
- ]}
878
- numberOfLines={numberOfLines}
879
- >
880
- {_renderDescription(rowData)}
881
- </Text>
882
- );
883
- };
884
-
885
- const _renderRow = (rowData = {}, index) => {
886
- return (
887
- <ScrollView
888
- contentContainerStyle={
889
- isRowScrollable ? { minWidth: '100%' } : { width: '100%' }
890
- }
891
- scrollEnabled={isRowScrollable}
892
- keyboardShouldPersistTaps={keyboardShouldPersistTaps}
893
- horizontal={true}
894
- showsHorizontalScrollIndicator={false}
895
- showsVerticalScrollIndicator={false}
896
- >
897
- <Pressable
898
- style={({ hovered, pressed }) => [
899
- isRowScrollable ? { minWidth: '100%' } : { width: '100%' },
900
- {
901
- backgroundColor: pressed
902
- ? listUnderlayColor
903
- : hovered
904
- ? listHoverColor
905
- : undefined,
906
- },
907
- ]}
908
- onPress={() => _onPress(rowData)}
909
- onBlur={_onBlur}
910
- >
911
- <View
912
- style={[
913
- suppressDefaultStyles ? {} : defaultStyles.row,
914
- styles?.row,
915
- rowData.isPredefinedPlace ? styles?.specialItemRow : {},
916
- ]}
917
- >
918
- {_renderLoader(rowData)}
919
- {_renderRowData(rowData, index)}
920
- </View>
921
- </Pressable>
922
- </ScrollView>
923
- );
924
- };
925
-
926
- const _renderSeparator = (sectionID, rowID) => {
927
- if (rowID === dataSource.length - 1) {
928
- return null;
929
- }
930
-
931
- return (
932
- <View
933
- key={`${sectionID}-${rowID}`}
934
- style={[
935
- suppressDefaultStyles ? {} : defaultStyles.separator,
936
- styles?.separator,
937
- ]}
938
- />
939
- );
940
- };
941
-
942
- const _shouldShowPoweredLogo = () => {
943
- if (!enablePoweredByContainer || dataSource.length === 0) {
944
- return false;
945
- }
946
-
947
- for (let i = 0; i < dataSource.length; i++) {
948
- const row = dataSource[i];
949
-
950
- if (!('isCurrentLocation' in row) && !('isPredefinedPlace' in row)) {
951
- return true;
952
- }
953
- }
954
-
955
- return false;
956
- };
957
-
958
- const _renderPoweredLogo = () => {
959
- if (!_shouldShowPoweredLogo()) {
960
- return null;
961
- }
962
-
963
- return (
964
- <View
965
- style={[
966
- suppressDefaultStyles ? {} : defaultStyles.row,
967
- defaultStyles.poweredContainer,
968
- styles?.poweredContainer,
969
- ]}
970
- >
971
- <Image
972
- style={[
973
- suppressDefaultStyles ? {} : defaultStyles.powered,
974
- styles?.powered,
975
- ]}
976
- resizeMode='contain'
977
- source={require('./images/powered_by_google_on_white.png')}
978
- />
979
- </View>
980
- );
981
- };
982
-
983
- const _renderLeftButton = () => {
984
- if (props.renderLeftButton) {
985
- return props.renderLeftButton();
986
- }
987
- return null;
988
- };
989
-
990
- const _renderRightButton = () => {
991
- if (props.renderRightButton) {
992
- return props.renderRightButton();
993
- }
994
- return null;
995
- };
996
-
997
- const _getFlatList = () => {
998
- const keyExtractor = (item, index) => {
999
- // Use stable keys based on item data
1000
- if (item.place_id) {
1001
- return `place_${item.place_id}_${index}`;
1002
- }
1003
- if (item.isCurrentLocation) {
1004
- return 'current_location';
1005
- }
1006
- if (item.isPredefinedPlace && item.description) {
1007
- return `predefined_${item.description}_${index}`;
1008
- }
1009
- // Fallback to index-based key (should rarely happen)
1010
- return `item_${index}`;
1011
- };
1012
-
1013
- // Show list if:
1014
- // 1. Platform is supported
1015
- // 2. There's data to show (dataSource has items)
1016
- // 3. listViewDisplayed is true OR we're in 'auto' mode (auto-shows when data exists)
1017
- const isAutoMode =
1018
- listViewDisplayedProp === 'auto' || listViewDisplayedProp === undefined;
1019
- const shouldShowList =
1020
- supportedPlatform() &&
1021
- dataSource.length > 0 &&
1022
- (listViewDisplayed === true || (isAutoMode && !listWasDismissed));
1023
-
1024
- if (shouldShowList) {
1025
- return (
1026
- <FlatList
1027
- nativeID='result-list-id'
1028
- scrollEnabled={!disableScroll}
1029
- nestedScrollEnabled={true}
1030
- keyboardShouldPersistTaps={keyboardShouldPersistTaps}
1031
- style={[
1032
- suppressDefaultStyles ? {} : defaultStyles.listView,
1033
- styles?.listView,
1034
- ]}
1035
- data={dataSource}
1036
- keyExtractor={keyExtractor}
1037
- extraData={[dataSource, props]}
1038
- ItemSeparatorComponent={_renderSeparator}
1039
- renderItem={({ item, index }) => _renderRow(item, index)}
1040
- ListEmptyComponent={
1041
- listLoaderDisplayed
1042
- ? props.listLoaderComponent
1043
- : stateText.length > minLength && props.listEmptyComponent
1044
- }
1045
- ListHeaderComponent={
1046
- props.renderHeaderComponent &&
1047
- props.renderHeaderComponent(stateText)
1048
- }
1049
- ListFooterComponent={_renderPoweredLogo}
1050
- {...restProps}
1051
- />
866
+ useEffect(() => {
867
+ if (currentLocation === true && !hasGeolocation) {
868
+ console.warn(
869
+ Platform.OS === 'web'
870
+ ? 'Geolocation is not available. For web, ensure your site is served over HTTPS or localhost to use geolocation features.'
871
+ : 'Geolocation is not available. For React Native, you may need to install and configure @react-native-community/geolocation or expo-location to enable currentLocation.',
1052
872
  );
1053
873
  }
874
+ }, [currentLocation, hasGeolocation]);
1054
875
 
1055
- return null;
1056
- };
1057
-
1058
- // ==========================================================================
1059
- // EFFECTS
1060
- // ==========================================================================
1061
-
1062
- // Update query ref when query changes
1063
- useEffect(() => {
1064
- queryRef.current = query;
1065
- }, [query]);
1066
-
1067
- // Initialize URL from requestUrl prop
1068
- useEffect(() => {
1069
- setUrl(getRequestUrl(props.requestUrl));
1070
- }, [props.requestUrl]);
1071
-
1072
- // Initialize dataSource on mount
1073
- useEffect(() => {
1074
- setDataSource(buildRowsFromResults([]));
1075
- // eslint-disable-next-line react-hooks/exhaustive-deps
1076
- }, []);
1077
-
1078
- // Keep requestRef updated
1079
- requestRef.current = _request;
1080
-
1081
- // Debounce setup
1082
- const debounceData = useMemo(() => {
1083
- return debounce((text) => requestRef.current(text), debounceMs);
1084
- }, [debounceMs]);
1085
-
876
+ // Rebuild the rows whenever the predefined-place configuration changes.
1086
877
  useEffect(() => {
1087
- return () => {
1088
- // Cleanup debounced function on unmount
1089
- if (debounceData.cancel) {
1090
- debounceData.cancel();
1091
- }
1092
- };
1093
- }, [debounceData]);
878
+ setDataSource(buildRows(resultsRef.current, stateTextRef.current));
879
+ }, [buildRows]);
1094
880
 
1095
- // Reload search when query changes (using string comparison to avoid object reference issues)
881
+ // Re-run the current search when the query object changes.
1096
882
  useEffect(() => {
1097
- const queryChanged = prevQueryStringRef.current !== queryString;
1098
-
1099
- if (queryChanged) {
883
+ if (prevQueryStringRef.current === null) {
1100
884
  prevQueryStringRef.current = queryString;
1101
- if (stateText && stateText.length >= minLength) {
1102
- debounceData(stateText);
1103
- }
885
+ return;
886
+ }
887
+ if (prevQueryStringRef.current === queryString) {
888
+ return;
1104
889
  }
1105
890
 
1106
- return () => {
1107
- _abortRequests();
1108
- };
1109
- }, [queryString, debounceData, stateText, minLength, _abortRequests]);
891
+ prevQueryStringRef.current = queryString;
892
+ const text = stateTextRef.current;
1110
893
 
1111
- // Auto-show list when dataSource has items in 'auto' mode
1112
- useEffect(() => {
1113
- if (
1114
- listViewDisplayedProp === 'auto' &&
1115
- dataSource.length > 0 &&
1116
- !listViewDisplayed &&
1117
- !listWasDismissed
1118
- ) {
1119
- setListViewDisplayed(true);
894
+ if (text && text.length >= minLength) {
895
+ debouncedRequest(text);
1120
896
  }
1121
- }, [
1122
- dataSource.length,
1123
- listViewDisplayedProp,
1124
- listViewDisplayed,
1125
- listWasDismissed,
1126
- ]);
897
+ }, [queryString, minLength, debouncedRequest]);
898
+
899
+ // Aborting belongs to the component lifetime, not to every keystroke. It used
900
+ // to be tangled into the query effect, whose `stateText` dependency meant a
901
+ // `preProcess` call would abort the very request it had just sent.
902
+ useEffect(
903
+ () => () => {
904
+ debouncedRequest.cancel();
905
+ abortSearchRequests();
906
+ abortDetailsRequest();
907
+ },
908
+ [debouncedRequest, abortSearchRequests, abortDetailsRequest],
909
+ );
1127
910
 
1128
- // ==========================================================================
1129
- // IMPERATIVE HANDLE
1130
- // ==========================================================================
911
+ // --------------------------------------------------------------------------
912
+ // Imperative handle
913
+ // --------------------------------------------------------------------------
1131
914
 
1132
915
  useImperativeHandle(
1133
916
  ref,
1134
917
  () => ({
1135
- setAddressText: (address) => {
1136
- setStateText(address);
1137
- },
1138
- getAddressText: () => stateText,
918
+ setAddressText: (address) => setStateText(address),
919
+ getAddressText: () => stateTextRef.current,
1139
920
  blur: () => inputRef.current?.blur(),
1140
921
  focus: () => inputRef.current?.focus(),
1141
922
  isFocused: () => inputRef.current?.isFocused(),
1142
923
  clear: () => inputRef.current?.clear(),
1143
924
  getCurrentLocation,
1144
925
  }),
1145
- [stateText, getCurrentLocation],
926
+ [getCurrentLocation],
927
+ );
928
+
929
+ // --------------------------------------------------------------------------
930
+ // Render
931
+ // --------------------------------------------------------------------------
932
+
933
+ const renderItem = useCallback(
934
+ ({ item, index }) => (
935
+ <Row
936
+ rowData={item}
937
+ index={index}
938
+ isLoading={loadingRowKey === getRowKey(item)}
939
+ isRowScrollable={isRowScrollable}
940
+ keyboardShouldPersistTaps={keyboardShouldPersistTaps}
941
+ listHoverColor={listHoverColor}
942
+ listUnderlayColor={listUnderlayColor}
943
+ mergedStyles={mergedStyles}
944
+ numberOfLines={numberOfLines}
945
+ onBlur={handleBlur}
946
+ onPress={handleRowPress}
947
+ renderDescription={renderRowDescription}
948
+ renderRow={renderRow}
949
+ />
950
+ ),
951
+ [
952
+ loadingRowKey,
953
+ isRowScrollable,
954
+ keyboardShouldPersistTaps,
955
+ listHoverColor,
956
+ listUnderlayColor,
957
+ mergedStyles,
958
+ numberOfLines,
959
+ handleBlur,
960
+ handleRowPress,
961
+ renderRowDescription,
962
+ renderRow,
963
+ ],
964
+ );
965
+
966
+ const keyExtractor = useCallback(
967
+ (item, index) => `${getRowKey(item)}:${index}`,
968
+ [],
969
+ );
970
+
971
+ const Separator = useMemo(
972
+ () =>
973
+ function ItemSeparator() {
974
+ return <View style={mergedStyles.separator} />;
975
+ },
976
+ [mergedStyles],
1146
977
  );
1147
978
 
1148
- // ==========================================================================
1149
- // MAIN RENDER
1150
- // ==========================================================================
979
+ const poweredComponent = useMemo(() => {
980
+ if (!enablePoweredByContainer || !hasApiResults(dataSource)) {
981
+ return null;
982
+ }
983
+
984
+ return (
985
+ <View style={mergedStyles.poweredContainer}>
986
+ <Image
987
+ style={mergedStyles.powered}
988
+ resizeMode='contain'
989
+ source={require('./images/powered_by_google_on_white.png')}
990
+ />
991
+ </View>
992
+ );
993
+ }, [enablePoweredByContainer, dataSource, mergedStyles]);
994
+
995
+ // The list used to be gated on `dataSource.length > 0`, which meant
996
+ // ListEmptyComponent — and therefore both listEmptyComponent and
997
+ // listLoaderComponent — could never render.
998
+ const emptyStateComponent = listLoaderDisplayed
999
+ ? listLoaderComponent
1000
+ : stateText.length > minLength
1001
+ ? listEmptyComponent
1002
+ : null;
1003
+
1004
+ const isListVisible = isAutoMode
1005
+ ? !listWasDismissed
1006
+ : listViewDisplayedProp === true;
1007
+
1008
+ const shouldShowList =
1009
+ isSupportedPlatform &&
1010
+ isListVisible &&
1011
+ (dataSource.length > 0 || Boolean(emptyStateComponent));
1151
1012
 
1152
1013
  const {
1153
- onFocus: textInputOnFocus,
1154
- onBlur: textInputOnBlur,
1155
- onChangeText: textInputOnChangeText, // destructuring here stops this being set after onChangeText={_handleChangeText}
1014
+ onFocus: _ignoredOnFocus,
1015
+ onBlur: _ignoredOnBlur,
1016
+ onChangeText: _ignoredOnChangeText,
1156
1017
  clearButtonMode,
1157
1018
  InputComp,
1158
- ...userProps
1159
- } = textInputProps || {};
1019
+ ...userTextInputProps
1020
+ } = textInputProps || EMPTY_OBJECT;
1021
+
1160
1022
  const TextInputComp = InputComp || TextInput;
1161
1023
 
1162
1024
  return (
1163
- <View
1164
- style={[
1165
- suppressDefaultStyles ? {} : defaultStyles.container,
1166
- styles?.container,
1167
- ]}
1168
- pointerEvents='box-none'
1169
- >
1025
+ <View style={mergedStyles.container} pointerEvents='box-none'>
1170
1026
  {!textInputHide && (
1171
- <View
1172
- style={[
1173
- suppressDefaultStyles ? {} : defaultStyles.textInputContainer,
1174
- styles?.textInputContainer,
1175
- ]}
1176
- >
1177
- {_renderLeftButton()}
1027
+ <View style={mergedStyles.textInputContainer}>
1028
+ {renderLeftButton ? renderLeftButton() : null}
1178
1029
  <TextInputComp
1179
1030
  ref={inputRef}
1180
- style={[
1181
- suppressDefaultStyles ? {} : defaultStyles.textInput,
1182
- styles?.textInput,
1183
- ]}
1031
+ style={mergedStyles.textInput}
1184
1032
  value={stateText}
1185
1033
  placeholder={placeholder}
1186
- onFocus={
1187
- textInputOnFocus
1188
- ? (e) => {
1189
- _onFocus();
1190
- textInputOnFocus(e);
1191
- }
1192
- : _onFocus
1193
- }
1194
- onBlur={
1195
- textInputOnBlur
1196
- ? (e) => {
1197
- _onBlur(e);
1198
- textInputOnBlur(e);
1199
- }
1200
- : _onBlur
1201
- }
1034
+ onFocus={handleFocus}
1035
+ onBlur={handleInputBlur}
1202
1036
  clearButtonMode={clearButtonMode || 'while-editing'}
1203
- onChangeText={_handleChangeText}
1204
- {...userProps}
1037
+ onChangeText={handleChangeText}
1038
+ {...userTextInputProps}
1205
1039
  />
1206
- {_renderRightButton()}
1040
+ {renderRightButton ? renderRightButton() : null}
1207
1041
  </View>
1208
1042
  )}
1209
- {props.inbetweenCompo}
1210
- {_getFlatList()}
1211
- {props.children}
1043
+
1044
+ {inbetweenCompo}
1045
+
1046
+ {shouldShowList ? (
1047
+ <FlatList
1048
+ nativeID='result-list-id'
1049
+ scrollEnabled={!disableScroll}
1050
+ nestedScrollEnabled={true}
1051
+ keyboardShouldPersistTaps={keyboardShouldPersistTaps}
1052
+ style={mergedStyles.listView}
1053
+ data={dataSource}
1054
+ extraData={loadingRowKey}
1055
+ keyExtractor={keyExtractor}
1056
+ ItemSeparatorComponent={Separator}
1057
+ renderItem={renderItem}
1058
+ ListEmptyComponent={emptyStateComponent}
1059
+ ListHeaderComponent={
1060
+ renderHeaderComponent ? renderHeaderComponent(stateText) : null
1061
+ }
1062
+ ListFooterComponent={poweredComponent}
1063
+ {...restProps}
1064
+ />
1065
+ ) : null}
1066
+
1067
+ {children}
1212
1068
  </View>
1213
1069
  );
1214
1070
  });