• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

yext / search-ui-react / 20143810141

11 Dec 2025 06:46PM UTC coverage: 86.062% (-1.8%) from 87.818%
20143810141

push

github

web-flow
feat: upgrade to React 19; add backwards compatibility test-site script (v1.11.0) (#589)

* upgrade to React 19; add backwards compatibility test-site script

* Automated update to THIRD-PARTY-NOTICES from github action's 3rd party notices check

* try updating run-tests.yml

* codex: try fixing old react errors

* update storybook command from p -> port

* update types and fiddle with Mapbox pin code to pass storybook tests

* wrap various tasts with act() to cut down on log warns

* try to update storybook config to get tests to pass

* Update snapshots

* Update snapshots

* Update snapshots

* Update snapshots

* rerun npm i in test site

* slight code cleanup

* Update snapshots

* Update snapshots

* Update snapshots

* Update snapshots

* Update snapshots

* Update snapshots

* Update snapshots

* nits

* Update snapshots

* Update snapshots

* update kill command in visual coverage

* Update snapshots

---------

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>

995 of 1346 branches covered (73.92%)

Branch coverage included in aggregate %.

94 of 96 new or added lines in 41 files covered. (97.92%)

2 existing lines in 1 file now uncovered.

2191 of 2356 relevant lines covered (93.0%)

133.79 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

75.09
/src/components/MapboxMap.tsx
1
import React, { useRef, useEffect, useState, useCallback } from 'react';
6✔
2
import mapboxgl, { MarkerOptions } from 'mapbox-gl';
6✔
3
import { Result, useSearchState, SelectableStaticFilter } from '@yext/search-headless-react';
6✔
4
import { useDebouncedFunction } from '../hooks/useDebouncedFunction';
6✔
5
import _ from 'lodash';
6✔
6

7
import ReactDOM from 'react-dom';
6✔
8
// Try to statically import createRoot, will be undefined for <18.
9
import * as ReactDomClient from 'react-dom/client';
6✔
10

11
type LegacyReactDOM = {
12
  render?: (element: React.ReactElement, container: Element) => void;
13
  unmountComponentAtNode?: (container: Element | DocumentFragment) => boolean;
14
};
15

16
type RootHandle = {
17
  render: (children: React.ReactNode) => void;
18
  unmount: () => void;
19
};
20

21
const legacyReactDOM = ReactDOM as LegacyReactDOM;
10✔
22
const reactMajorVersion = Number(React.version.split('.')[0]);
10✔
23
const supportsCreateRoot = !Number.isNaN(reactMajorVersion) && reactMajorVersion >= 18;
10✔
24

25

26
/**
27
 * Props for rendering a custom marker on the map.
28
 *
29
 * @public
30
 */
31
export type PinComponentProps<T> = {
32
  /** The index of the pin. */
33
  index: number,
34
  /** The Mapbox map. */
35
  mapbox: mapboxgl.Map,
36
  /** The search result corresponding to the pin. */
37
  result: Result<T>,
38
  /** Where the pin is selected. */
39
  selected?: boolean,
40
};
41

42
/**
43
 * A functional component that can be used to render a custom marker on the map.
44
 *
45
 * @public
46
 */
47
export type PinComponent<T> = (props: PinComponentProps<T>) => React.JSX.Element;
48

49
/**
50
 * A function use to derive a result's coordinate.
51
 *
52
 * @public
53
 */
54
export type CoordinateGetter<T> = (result: Result<T>) => Coordinate | undefined;
55

56
/**
57
 * Coordinate use to represent the result's location on a map.
58
 *
59
 * @public
60
 */
61
export interface Coordinate {
62
  /** The latitude of the location. */
63
  latitude: number,
64
  /** The longitude of the location. */
65
  longitude: number
66
}
67

68
/**
69
 * A function which is called when user drags or zooms the map.
70
 *
71
 * @public
72
 */
73
export type OnDragHandler = (center: mapboxgl.LngLat, bounds: mapboxgl.LngLatBounds) => void;
74

75
/**
76
 * Props for the {@link MapboxMap} component.
77
 * The type param "T" represents the type of "rawData" field of the results use in the map.
78
 *
79
 * @public
80
 */
81
export interface MapboxMapProps<T> {
82
  /** Mapbox access token. */
83
  mapboxAccessToken: string,
84
  /** Interface for map customization derived from Mapbox GL's Map options. */
85
  mapboxOptions?: Omit<mapboxgl.MapboxOptions, 'container'>,
86
  /**
87
   * Custom Pin component to render for markers on the map.
88
   * By default, the built-in marker image from Mapbox GL is used.
89
   * This prop should not be used with
90
   * {@link MapboxMapProps.renderPin | renderPin}. If both are provided,
91
   * only PinComponent will be used.
92
   */
93
  PinComponent?: PinComponent<T>,
94
  /**
95
   * Render function for a custom marker on the map. This function takes in an
96
   * HTML element and is responible for rendering the pin into that element,
97
   * which will be used as the marker.
98
   * By default, the built-in marker image from Mapbox GL is used.
99
   * This prop should not be used with
100
   * {@link MapboxMapProps.PinComponent | PinComponent}. If both are provided,
101
   * only PinComponent will be used.
102
   */
103
  renderPin?: (props: PinComponentProps<T> & { container: HTMLElement }) => void,
104
  /**
105
   * A function to derive a result's coordinate for the corresponding marker's location on the map.
106
   * By default, "yextDisplayCoordinate" field is used as the result's display coordinate.
107
   */
108
  getCoordinate?: CoordinateGetter<T>,
109
  /** {@inheritDoc OnDragHandler} */
110
  onDrag?: OnDragHandler,
111
  /**
112
   * The window object of the iframe where the map should rendered. Must have mapboxgl loaded.
113
   * If not provided or mapboxgl not loaded, the map will be rendered in the parent window.
114
   */
115
  iframeWindow?: Window,
116
  /**
117
   * If set to true, the map will update its options when the mapboxOptions prop changes.
118
   * Otherwise, the map will not update its options once initially set.
119
   */
120
  allowUpdates?: boolean,
121
  /** A function that handles a pin click event. */
122
  onPinClick?: (result: Result<T> | undefined) => void,
123
  /** The options to apply to the map markers based on whether it is selected. */
124
  markerOptionsOverride?: (selected: boolean) => MarkerOptions,
125
}
126

127
/**
128
 * A component that renders a map with markers to show result locations using Mapbox GL.
129
 *
130
 * @remarks
131
 * For the map to work properly, be sure to include Mapbox GL stylesheet in the application.
132
 *
133
 * @example
134
 * For instance, user may add the following import statement in their application's index file
135
 * or in the file where `MapboxMap` is used:
136
 * `import 'mapbox-gl/dist/mapbox-gl.css';`
137
 *
138
 * Or, user may add a stylesheet link in their html page:
139
 * `<link href="https://api.mapbox.com/mapbox-gl-js/v2.9.2/mapbox-gl.css" rel="stylesheet" />`
140
 *
141
 * @param props - {@link MapboxMapProps}
142
 * @returns A React element containing a Mapbox Map
143
 *
144
 * @public
145
 */
146
export function MapboxMap<T>({
6✔
147
  mapboxAccessToken,
148
  mapboxOptions,
149
  PinComponent,
150
  renderPin,
151
  getCoordinate = getDefaultCoordinate,
18✔
152
  onDrag,
153
  iframeWindow,
154
  allowUpdates = false,
19✔
155
  onPinClick,
156
  markerOptionsOverride,
157
}: MapboxMapProps<T>): React.JSX.Element {
158
  const mapboxInstance = (iframeWindow as Window & { mapboxgl?: typeof mapboxgl })?.mapboxgl ?? mapboxgl;
19✔
159
  useEffect(() => {
13✔
160
    mapboxInstance.accessToken = mapboxAccessToken;
10✔
161
  }, [mapboxAccessToken]);
162

163
  const mapContainer = useRef<HTMLDivElement>(null);
19✔
164
  const map = useRef<mapboxgl.Map | null>(null);
19✔
165
  const markers = useRef<mapboxgl.Marker[]>([]);
19✔
166
  const markerRoots = useRef(new Map<HTMLElement, RootHandle>());
19✔
167
  const activeMarkerElements = useRef(new Set<HTMLElement>());
19✔
168
  const markerData = useRef<Array<{ marker: mapboxgl.Marker, result: Result<T>, index: number }>>([]);
19✔
169

170
  const locationResults = useSearchState(state => state.vertical.results) as Result<T>[];
19✔
171
  const staticFilters = useSearchState(state => state.filters?.static);
19✔
172
  const onDragDebounced = useDebouncedFunction(onDrag, 100);
19✔
173
  const [selectedResult, setSelectedResult] = useState<Result<T> | undefined>(undefined);
19✔
174

175
  const handlePinClick = useCallback((result: Result<T>) => {
13✔
176
    setSelectedResult(prev => prev === result ? undefined : result)
3!
177
  }, [])
178

179
  useEffect(() => {
19✔
180
    onPinClick?.(selectedResult);
13✔
181
  }, [selectedResult])
182

183
  const cleanupPinComponent = useCallback((element: HTMLElement) => {
19✔
184
    activeMarkerElements.current.delete(element);
13✔
185
    if (supportsCreateRoot) {
13!
186
      const root = markerRoots.current.get(element);
10✔
187
      if (root) {
13!
188
        root.unmount();
13✔
189
        markerRoots.current.delete(element);
13✔
190
      }
191
    } else {
192
      legacyReactDOM.unmountComponentAtNode?.(element);
13✔
193
    }
194
  }, []);
195

196
  const attachPinComponent = useCallback((element: HTMLElement, component: React.JSX.Element) => {
19✔
197
    if (supportsCreateRoot && typeof ReactDomClient.createRoot === 'function') {
14!
198
      // Use React 18+ API
199
      let root = markerRoots.current.get(element);
7✔
200
      if (!root) {
14✔
201
        root = ReactDomClient.createRoot(element);
14✔
202
        markerRoots.current.set(element, root);
14✔
203
      }
204
      root.render(component);
14✔
205
    } else if (typeof legacyReactDOM.render === 'function') {
13!
206
      // Fallback for React <18
207
      legacyReactDOM.render(component, element);
13✔
208
    }
209
  }, []);
210

211
  const removeMarkers = useCallback(() => {
19✔
212
    markers.current.forEach(marker => {
24✔
213
      if (!marker) {
16!
214
        return;
16✔
215
      }
216
      const element = typeof marker.getElement === 'function' ? marker.getElement() : null;
10!
217
      if (element) {
13!
218
        cleanupPinComponent(element);
13✔
219
      }
220
      if (typeof marker.remove === 'function') {
13!
221
        marker.remove();
13✔
222
      }
223
    });
224
    markers.current = [];
24✔
225
    markerData.current = [];
24✔
226
  }, [cleanupPinComponent]);
227

228
  const locale = useSearchState(state => state.meta?.locale);
19✔
229
  // keep track of the previous value of mapboxOptions across renders
230
  const prevMapboxOptions = useRef(mapboxOptions);
19✔
231

232
  const localizeMap = useCallback(() => {
19✔
233
    const mapbox = map.current;
8✔
234
    if (!mapbox || !locale) return;
18✔
235

236
    const localizeLabels = () => {
13✔
237
      mapbox.getStyle().layers.forEach(layer => {
13✔
238
        if (layer.type === "symbol" && layer.layout?.["text-field"]) {
13!
239
          mapbox.setLayoutProperty(
13✔
240
            layer.id,
241
            "text-field",
242
            [
243
              'coalesce',
244
              ['get', `name_${getMapboxLanguage(locale)}`],
245
              ['get', 'name']
246
            ]
247
          );
248
        }
249
      });
250
    }
251

252
    if (mapbox.isStyleLoaded()) {
13!
253
      localizeLabels();
13✔
254
    } else {
255
      mapbox.once("styledata", () => localizeLabels())
×
256
    }
257
  }, [locale]);
258

259
  useEffect(() => {
13✔
260
    if (mapContainer.current) {
13✔
261
      if (map.current && allowUpdates) {
13!
262
        // Compare current and previous mapboxOptions using deep equality
263
        if (!_.isEqual(prevMapboxOptions.current, mapboxOptions)) {
×
264
          // Update to existing Map
265
          handleMapboxOptionsUpdates(mapboxOptions, map.current);
×
266
          prevMapboxOptions.current = (mapboxOptions);
×
267
        }
268
      } else if (!map.current && mapboxInstance) {
13✔
269
        const options: mapboxgl.MapboxOptions = {
16✔
270
          container: mapContainer.current,
271
          style: 'mapbox://styles/mapbox/streets-v11',
272
          center: [-74.005371, 40.741611],
273
          zoom: 9,
274
          ...mapboxOptions
275
        };
276
        map.current = new mapboxInstance.Map(options);
10✔
277
        const mapbox = map.current;
16✔
278
        mapbox.resize();
10✔
279
        const nav = new mapboxInstance.NavigationControl({
16✔
280
          showCompass: false,
281
          showZoom: true,
282
          visualizePitch: false
283
        });
284
        mapbox.addControl(nav, 'top-right');
10✔
285
        if (onDragDebounced) {
10✔
286
          mapbox.on('drag', () => {
10✔
287
            onDragDebounced(mapbox.getCenter(), mapbox.getBounds());
1✔
288
          });
289
          mapbox.on('zoom', (e) => {
10✔
290
            if (e.originalEvent) {
148!
291
              // only trigger on user zoom, not programmatic zoom (e.g. from fitBounds)
292
              onDragDebounced(mapbox.getCenter(), mapbox.getBounds());
×
293
            }
294
          });
295
          return () => {
10✔
296
            mapbox.off('drag', () => {
8✔
297
              onDragDebounced(mapbox.getCenter(), mapbox.getBounds());
×
298
            });
299
            mapbox.off('zoom', (e) => {
8✔
300
              if (e.originalEvent) {
×
301
                onDragDebounced(mapbox.getCenter(), mapbox.getBounds());
×
302
              }
303
            });
304
          };
305
        }
306
      }
307
      localizeMap();
5✔
308
    }
309
  }, [mapboxOptions, onDragDebounced, localizeMap]);
310

311
  useEffect(() => {
13✔
312
    if (iframeWindow && map.current) {
13!
313
      map.current.resize();
×
314
    }
315
  }, [mapContainer.current]);
316

317
  useEffect(() => {
13✔
318
    removeMarkers();
10✔
319
    const mapbox = map.current;
16✔
320
    if (mapbox && locationResults) {
10✔
321
      if (locationResults.length > 0) {
15!
322
        const bounds = new mapboxInstance.LngLatBounds();
15✔
323
        locationResults.forEach((result, i) => {
15✔
324
          const markerLocation = getCoordinate(result);
21✔
325
          if (markerLocation) {
16✔
326
            const { latitude, longitude } = markerLocation;
19✔
327
            const el = document.createElement('div');
19✔
328
            let markerOptions: mapboxgl.MarkerOptions = {};
19✔
329
            if (PinComponent) {
16✔
330
              if (renderPin) {
11!
331
                console.warn(
11✔
332
                  'Found both PinComponent and renderPin props. Using PinComponent.'
333
                );
334
              }
335
              attachPinComponent(el, (
11✔
336
                <PinComponent
337
                  index={i}
338
                  mapbox={mapbox}
339
                  result={result}
340
                  selected={selectedResult === result}
341
                />
342
              ));
343
              markerOptions.element = el;
11✔
344
            } else if (renderPin) {
14✔
345
              renderPin({ index: i, mapbox, result, container: el });
10✔
346
              markerOptions.element = el;
10✔
347
            }
348

349
            if (markerOptionsOverride) {
16!
UNCOV
350
              markerOptions = {
×
351
                ...markerOptions,
352
                ...markerOptionsOverride(selectedResult === result)
353
              }
354
            }
355

356
            const marker = new mapboxInstance.Marker(markerOptions)
19✔
357
              .setLngLat({ lat: latitude, lng: longitude })
358
              .addTo(mapbox);
359

360
            marker?.getElement().addEventListener('click', () => handlePinClick(result));
16✔
361
            markers.current.push(marker);
16✔
362
            if (marker) {
16✔
363
              markerData.current.push({ marker, result, index: i });
16✔
364
            }
365
            bounds.extend([longitude, latitude]);
16✔
366
          }
367
        })
368

369
        if (!bounds.isEmpty()){
15✔
370
          mapbox.fitBounds(bounds, {
15✔
371
            // these settings are defaults and will be overriden if present on fitBoundsOptions
372
            padding: { top: 50, bottom: 50, left: 50, right: 50 },
373
            maxZoom: mapboxOptions?.maxZoom ?? 15,
30✔
374
            ...mapboxOptions?.fitBoundsOptions,
375
          });
376
        }
377

378
        return () => {
15✔
379
          markers.current.forEach((marker, i) => {
15✔
380
            marker?.getElement().removeEventListener('click', () => handlePinClick(locationResults[i]));
13✔
381
          });
382
          removeMarkers();
15✔
383
        }
384
      } else if (staticFilters?.length) {
×
385
        const locationFilterValue = getLocationFilterValue(staticFilters);
×
386
        if (locationFilterValue) {
×
387
          mapbox.flyTo({
×
388
            center: locationFilterValue
389
          });
390
        }
391
      };
392
    }
393
  }, [PinComponent, getCoordinate, locationResults, removeMarkers, markerOptionsOverride, renderPin]);
394

395
  useEffect(() => {
13✔
396
    const mapbox = map.current;
19✔
397
    if (!mapbox || !PinComponent) {
13✔
398
      return;
9✔
399
    }
400
    markerData.current.forEach(({ marker, result, index }) => {
4✔
401
      const element = typeof marker.getElement === 'function' ? marker.getElement() : null;
4!
402
      if (!element) {
4!
NEW
403
        return;
×
404
      }
405
      attachPinComponent(element, (
4✔
406
        <PinComponent
407
          index={index}
408
          mapbox={mapbox}
409
          result={result}
410
          selected={selectedResult === result}
411
        />
412
      ));
413
    });
414
  }, [attachPinComponent, PinComponent, selectedResult]);
415

416
  return (
13✔
417
    <div ref={mapContainer} className='h-full w-full' />
418
  );
419
}
420

421
function handleMapboxOptionsUpdates(mapboxOptions: Omit<mapboxgl.MapboxOptions, 'container'> | undefined, currentMap: mapboxgl.Map) {
422
  if (mapboxOptions?.style) {
×
423
    currentMap.setStyle(mapboxOptions.style);
×
424
  }
425
  // Add more options to update as needed
426
}
427

428
function isCoordinate(data: unknown): data is Coordinate {
429
  return typeof data == 'object'
16✔
430
    && typeof data?.['latitude'] === 'number'
431
    && typeof data?.['longitude'] === 'number';
432
}
433

434
function getDefaultCoordinate<T>(result: Result<T>): Coordinate | undefined {
435
  const yextDisplayCoordinate = result.rawData['yextDisplayCoordinate'];
20✔
436
  if (!yextDisplayCoordinate) {
16!
437
    console.error('Unable to use the default "yextDisplayCoordinate" field as the result\'s coordinate to display on map.'
1✔
438
    + '\nConsider providing the "getCoordinate" prop to MapboxMap component to fetch the desire coordinate from result.');
439
    return undefined;
1✔
440
  }
441
  if (!isCoordinate(yextDisplayCoordinate)) {
16!
442
    console.error('The default `yextDisplayCoordinate` field from result is not of type "Coordinate".');
1✔
443
    return undefined;
1✔
444
  }
445
  return yextDisplayCoordinate;
16✔
446
}
447

448
export function getMapboxLanguage(locale: string) {
6✔
449
  try {
22✔
450
    const localeOptions = new Intl.Locale(locale.replaceAll('_', '-'));
22✔
451
    return localeOptions.script ? `${localeOptions.language}-${localeOptions.script}` : localeOptions.language;
22✔
452
  } catch (e) {
UNCOV
453
    console.warn(`Locale "${locale}" is not supported.`)
×
454
  }
455
  return 'en';
×
456
}
457

458
function getLocationFilterValue(staticFilters: SelectableStaticFilter[]): [number, number] | undefined {
459
  const locationFilter = staticFilters.find(f => f.filter['fieldId'] === 'builtin.location' && f.filter['value'])?.filter;
×
460
  if (locationFilter) {
×
461
    const {lat, lng} = locationFilter['value'];
×
462
    return [lng, lat];
×
463
  }
464
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc