Skip to content
Closed
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
588c55f
fix(checkout): CHECKOUT-10026 migrate Google Places script loader to …
bc-maxy Jun 17, 2026
bd75dcc
fix(checkout): CHECKOUT-10026 Migrate GoogleAutocompleteService
bc-maxy Jun 17, 2026
a9876e5
fix(checkout): CHECKOUT-10026 Update GoogleAutocomplete file
bc-maxy Jun 19, 2026
1322a5a
fix(checkout): CHECKOUT-10026 Various quick fixes
bc-maxy Jun 19, 2026
f7af121
fix(checkout): CHECKOUT-10026 Resolve cursor comments
bc-maxy Jun 19, 2026
a5d1b4b
fix(checkout): CHECKOUT-10026 Add session token to suggestion calls
bc-maxy Jun 19, 2026
e02bfce
fix(checkout): CHECKOUT-10026 Various small cleanup
bc-maxy Jun 19, 2026
b93d093
fix(checkout): CHECKOUT-10026 Keep the old service alive
bc-maxy Jun 24, 2026
491717a
chore(checkout): CHECKOUT-10026 Clean up the code
bc-maxy Jun 24, 2026
dd051c8
feat(checkout): CHECKOUT-10026 Fall back to new service if old one fails
bc-maxy Jun 24, 2026
0e95de2
fix(checkout): CHECKOUT-10026 Adjust functions to work correctly
bc-maxy Jun 24, 2026
9389b14
fix(checkout): CHECKOUT-10026 Use module scoped variable
bc-maxy Jun 24, 2026
9672f94
fix(checkout): CHECKOUT-10026 Fix review comments
bc-maxy Jun 29, 2026
9796c11
fix(checkout): CHECKOUT-10026 Clean up the implementation
bc-maxy Jun 29, 2026
acad608
fix(checkout): CHECKOUT-10026 Update test file path
bc-maxy Jun 29, 2026
164211b
fix(checkout): CHECKOUT-10026 Try new service first, fall back to old
bc-maxy Jun 30, 2026
34537f2
fix(checkout): CHECKOUT-10026 Clean up the implementation
bc-maxy Jun 30, 2026
bcc8a31
fix(checkout): CHECKOUT-10026 A small refactor
bc-maxy Jun 30, 2026
974d130
chore(checkout): CHECKOUT-10026 Fix typos and unused exports
bc-maxy Jun 30, 2026
45efb30
fix(checkout): CHECKOUT-10026 Fall back to legacy only for permission…
bc-maxy Jul 1, 2026
9648939
fix(checkout): CHECKOUT-10026 Hide behind feature flag WIP
bc-maxy Jul 1, 2026
08fc93e
Merge branch 'master' into checkout-10026
bc-maxy Jul 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import userEvent from '@testing-library/user-event';
import React from 'react';

import { render, screen, waitFor } from '@bigcommerce/checkout/test-utils';

import GoogleAutocomplete, {
type GoogleAutocompleteProps,
newGooglePlacesApiState,
} from './GoogleAutocomplete';
import LegacyGoogleAutocompleteService from './GoogleAutocompleteService';
import { NewGooglePlacesApiService } from './newGooglePlacesApi/NewGooglePlacesApiService';

jest.mock('./GoogleAutocompleteService');
jest.mock('./newGooglePlacesApi/NewGooglePlacesApiService');

const MockLegacyService = LegacyGoogleAutocompleteService as jest.MockedClass<
typeof LegacyGoogleAutocompleteService
>;
const MockPlacesApiService = NewGooglePlacesApiService as jest.MockedClass<
typeof NewGooglePlacesApiService
>;

const legacySuggestions = [
{
description: '123 Legacy St, New York',
structured_formatting: { main_text: '123 Legacy St' },
matched_substrings: [],
place_id: 'legacy-place-1',
},
];

const legacyPlaceResult = {
name: '123 Legacy St',
address_components: [],
} as google.maps.places.PlaceResult;

const newApiSuggestions = [
{ id: 'new-place-1', label: '123 New API Ave', value: '123 New API Ave' },
];

const newApiPlaceResult = {
name: '123 New API Ave',
address_components: [],
} as google.maps.places.PlaceResult;

describe('GoogleAutocomplete', () => {
let mockGetPlacePredictions: jest.Mock;
let mockGetDetails: jest.Mock;
let mockGetSuggestions: jest.Mock;
let mockGetPlaceDetails: jest.Mock;
let defaultProps: GoogleAutocompleteProps;

beforeEach(() => {
newGooglePlacesApiState.isUnavailable = false;

mockGetPlacePredictions = jest.fn();
mockGetDetails = jest.fn();
mockGetSuggestions = jest.fn().mockResolvedValue(newApiSuggestions);
mockGetPlaceDetails = jest.fn().mockResolvedValue(newApiPlaceResult);

MockLegacyService.mockImplementation(
() =>
({
getAutocompleteService: jest
.fn()
.mockResolvedValue({ getPlacePredictions: mockGetPlacePredictions }),
getPlacesServices: jest.fn().mockResolvedValue({ getDetails: mockGetDetails }),
}) as unknown as LegacyGoogleAutocompleteService,
);

MockPlacesApiService.mockImplementation(
() =>
({
getSuggestions: mockGetSuggestions,
getPlaceDetails: mockGetPlaceDetails,
}) as unknown as NewGooglePlacesApiService,
);

defaultProps = {
apiKey: 'test-api-key',
isAutocompleteEnabled: true,
onSelect: jest.fn(),
onChange: jest.fn(),
};
});

describe('new API available (preferred path)', () => {
it('shows suggestions from the new API', async () => {
render(<GoogleAutocomplete {...defaultProps} />);

await userEvent.type(screen.getByRole('textbox'), '123');

await screen.findByText('123 New API Ave');
expect(mockGetPlacePredictions).not.toHaveBeenCalled();
});

it('debounces rapid keystrokes into a single new API request', async () => {
render(<GoogleAutocomplete {...defaultProps} />);

await userEvent.type(screen.getByRole('textbox'), '123');

await screen.findByText('123 New API Ave');

// Three characters, one request — for the full final value.
expect(mockGetSuggestions).toHaveBeenCalledTimes(1);
expect(mockGetSuggestions).toHaveBeenCalledWith('123', undefined, undefined);
});

it('calls onSelect with the new API place result when a suggestion is picked', async () => {
render(<GoogleAutocomplete {...defaultProps} />);

await userEvent.type(screen.getByRole('textbox'), '123');
await screen.findByText('123 New API Ave');
await userEvent.click(screen.getByText('123 New API Ave'));

await waitFor(() =>
expect(defaultProps.onSelect).toHaveBeenCalledWith(
newApiPlaceResult,
expect.objectContaining({ id: 'new-place-1' }),
),
);
expect(mockGetDetails).not.toHaveBeenCalled();
});
});

describe('new API unavailable (fall back to legacy)', () => {
beforeEach(() => {
mockGetPlacePredictions.mockImplementation((_req, cb) => cb(legacySuggestions, 'OK'));
mockGetDetails.mockImplementation((_req, cb) => cb(legacyPlaceResult, 'OK'));
});

it('falls back to the legacy service for suggestions when the new API rejects', async () => {
mockGetSuggestions.mockRejectedValue(new Error('new API down'));

render(<GoogleAutocomplete {...defaultProps} />);

await userEvent.type(screen.getByRole('textbox'), '123');

await screen.findByText('123 Legacy St, New York');
expect(mockGetPlacePredictions).toHaveBeenCalled();
});

it('skips the new API on later input once it has failed', async () => {
mockGetSuggestions.mockRejectedValue(new Error('new API down'));

render(<GoogleAutocomplete {...defaultProps} />);

const input = screen.getByRole('textbox');

// First debounced request hits the new API, fails, and flips the fallback flag.
await userEvent.type(input, '1');
await screen.findByText('123 Legacy St, New York');
expect(mockGetSuggestions).toHaveBeenCalledTimes(1);

// A later request goes straight to legacy without retrying the new API.
await userEvent.type(input, '2');
await waitFor(() => expect(mockGetPlacePredictions).toHaveBeenCalledTimes(2));
expect(mockGetSuggestions).toHaveBeenCalledTimes(1);
});

it('falls back to the legacy service for place details when the new API rejects', async () => {
// Suggestions still come from the new API so the dropdown has an item to click,
// but getPlaceDetails rejects, forcing the legacy getDetails fallback.
mockGetPlaceDetails.mockRejectedValue(new Error('new API down'));

render(<GoogleAutocomplete {...defaultProps} />);

await userEvent.type(screen.getByRole('textbox'), '1');
await screen.findByText('123 New API Ave');
await userEvent.click(screen.getByText('123 New API Ave'));

await waitFor(() =>
expect(defaultProps.onSelect).toHaveBeenCalledWith(
legacyPlaceResult,
expect.objectContaining({ id: 'new-place-1' }),
),
);
expect(mockGetDetails).toHaveBeenCalled();
});
});
});
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { noop } from 'lodash';
import React, { useRef, useState } from 'react';
import { debounce, noop } from 'lodash';
import React, { useEffect, useMemo, useRef, useState } from 'react';

import { Autocomplete, type AutocompleteItem } from '@bigcommerce/checkout/ui';

import GoogleAutocompleteService from './GoogleAutocompleteService';
import { type GoogleAutocompleteOptionTypes } from './googleAutocompleteTypes';
import { NewGooglePlacesApiService } from './newGooglePlacesApi';
import './GoogleAutocomplete.scss';

export interface GoogleAutocompleteProps {
Expand Down Expand Up @@ -32,6 +33,10 @@ const toAutocompleteItems = (
}));
};

export const newGooglePlacesApiState = { isUnavailable: false };

const SUGGESTIONS_DEBOUNCE_MS = 300;

const GoogleAutocomplete: React.FC<GoogleAutocompleteProps> = ({
initialValue,
onToggleOpen = noop,
Expand All @@ -47,32 +52,84 @@ const GoogleAutocomplete: React.FC<GoogleAutocompleteProps> = ({
}) => {
const [items, setItems] = useState<AutocompleteItem[]>([]);
const [autoComplete, setAutoComplete] = useState<string>('off');
const newGooglePlacesApiServiceRef = useRef<NewGooglePlacesApiService>();
const googleAutocompleteServiceRef = useRef<GoogleAutocompleteService>();

if (!newGooglePlacesApiServiceRef.current) {
newGooglePlacesApiServiceRef.current = new NewGooglePlacesApiService(apiKey);
}

if (!googleAutocompleteServiceRef.current) {
googleAutocompleteServiceRef.current = new GoogleAutocompleteService(apiKey);
}

const finalizeSelection = (
place: google.maps.places.PlaceResult | null,
item: AutocompleteItem,
) => {
if (nextElement) {
nextElement.focus();
}

onSelect(place, item);
};

const handleFetchLegacySuggestions = (input: string): void => {
googleAutocompleteServiceRef
.current!.getAutocompleteService()
.then((autocompleteService) => {
autocompleteService.getPlacePredictions(
{ input, types: types || ['geocode'], componentRestrictions },
(results) => {
setItems(toAutocompleteItems(results ?? undefined));
},
);
})
.catch(noop);
};

const handleFetchNewApiSuggestions = (input: string): void => {
newGooglePlacesApiServiceRef
.current!.getSuggestions(input, types, componentRestrictions)
.then(setItems)
.catch(() => {
newGooglePlacesApiState.isUnavailable = true;
handleFetchLegacySuggestions(input);
});
Comment thread
bc-maxy marked this conversation as resolved.
};

const handleSelectViaLegacy = (item: AutocompleteItem): void => {
googleAutocompleteServiceRef
.current!.getPlacesServices()
.then((placesService) => {
placesService.getDetails(
{ placeId: item.id, fields: fields || ['address_components', 'name'] },
(result) => {
finalizeSelection(result, item);
},
);
})
.catch(noop);
};

const handleSelectViaNewApi = (item: AutocompleteItem): void => {
newGooglePlacesApiServiceRef
.current!.getPlaceDetails(item.id, fields)
.then((result) => finalizeSelection(result, item))
.catch(() => {
newGooglePlacesApiState.isUnavailable = true;
handleSelectViaLegacy(item);
});
};

const onSelectHandler = (item: AutocompleteItem) => {
const service = googleAutocompleteServiceRef.current;

if (!service) return;

service.getPlacesServices().then((placesService) => {
placesService.getDetails(
{
placeId: item.id,
fields: fields || ['address_components', 'name'],
},
(result) => {
if (nextElement) {
nextElement.focus();
}

onSelect(result, item);
},
);
});
if (newGooglePlacesApiState.isUnavailable) {
handleSelectViaLegacy(item);

return;
}

handleSelectViaNewApi(item);
};

const resetAutocomplete = (): void => {
Expand All @@ -84,42 +141,53 @@ const GoogleAutocomplete: React.FC<GoogleAutocompleteProps> = ({
setAutoComplete(input && input.length ? 'nope' : 'off');
};

const setItemsFromInput = (input: string): void => {
if (!input) {
setItems([]);
const fetchSuggestions = (input: string): void => {
if (newGooglePlacesApiState.isUnavailable) {
handleFetchLegacySuggestions(input);

return;
}

const service = googleAutocompleteServiceRef.current;
handleFetchNewApiSuggestions(input);
};

if (!service) return;
const fetchSuggestionsRef = useRef(fetchSuggestions);

service.getAutocompleteService().then((autocompleteService) => {
autocompleteService.getPlacePredictions(
{
input,
types: types || ['geocode'],
componentRestrictions,
},
(results) => {
const autocompleteItems = toAutocompleteItems(results ?? undefined);
fetchSuggestionsRef.current = fetchSuggestions;

setItems(autocompleteItems);
},
);
});
};
// using ref to make sure the function is always up to date
const debouncedFetchSuggestions = useMemo(
() =>
debounce(
(input: string) => fetchSuggestionsRef.current(input),
SUGGESTIONS_DEBOUNCE_MS,
),
[],
);

// cleanup debounce on component unmount
useEffect(() => () => debouncedFetchSuggestions.cancel(), [debouncedFetchSuggestions]);

const onChangeHandler = (input: string) => {
onChange(input, false);

if (!isAutocompleteEnabled) {
debouncedFetchSuggestions.cancel();

return resetAutocomplete();
}

setAutocompleteValue(input);
setItemsFromInput(input);

// Clearing the field should empty the list immediately, not after the debounce.
if (!input) {
debouncedFetchSuggestions.cancel();
setItems([]);

return;
}

debouncedFetchSuggestions(input);
};

return (
Expand Down
Loading