Skip to content
5 changes: 5 additions & 0 deletions .changeset/bounded-config-polling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@godaddy/gd-commerce-storefront': minor
---

Configuration polling while the server reports `unbound` or `connecting` now ends. After the page loads, the provider polls `/api/commerce/config` every five seconds for five minutes, then every minute, and stops at thirty minutes, whether or not requests fail. Before, it polled every five seconds for as long as the page stayed visible. Reloading the page or a change in the reported state (for example, a focus refresh finding `unbound` became `connecting`) starts polling again, and focus and `retryConnection()` still refresh configuration. A page still `connecting` when polling stops shows the connection error with "Retry connection" instead of waiting indefinitely. Retrying shows connecting again and starts a new polling period; a failed retry shows connecting for at least a second before the error returns, so the click always has visible feedback. Retrying without an error refreshes without changing the connection state, so carts are kept.
16 changes: 10 additions & 6 deletions packages/commerce-storefront/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -525,12 +525,16 @@ mutation methods apply only to the connected store; sample cart data is internal
without `sampleProducts`, and direct product-detail visits, show the unconnected state.
Sample grids and drawers have the attribute `data-commerce-source="sample"`.

The provider reads configuration on mount and focus, and every five seconds while the page is
visible and the state is `unbound` or `connecting`. The first failed refresh hides samples and
clears the sample cart; a later `unbound` response starts an empty sample cart. While
connecting, refresh failures keep the connecting state for up to 60 seconds before showing
the error. Polling stops
when the state is `ready`; focus and explicit retry still refresh configuration.
The provider reads configuration on mount and focus. While the page is visible and the state is
`unbound` or `connecting`, it also polls every five seconds for five minutes after the page loads,
then every minute, and stops thirty minutes after load. Reloading, retrying or a change in the
reported state starts it again. A page still `connecting` when polling stops shows an error with
"Retry connection". The first
failed refresh hides samples and clears the sample cart; a later `unbound` response starts an
empty sample cart. While connecting, refresh failures keep the connecting
state for up to 60 seconds before showing the error. Once polling slows to every minute, the next
failure is already 60 seconds after the last success, so it shows the error at once. Polling
stops when the state is `ready`; focus and explicit retry still refresh configuration.

Use a server that reports connection state before you enable `sampleProducts`. Successful
config responses without `state` still mean ready. A server that does not report `unbound`
Expand Down
15 changes: 10 additions & 5 deletions packages/commerce-storefront/docs/server-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,14 @@ live data only when the deployed server reports a new state. If a host's deploym
state from deployment configuration, a deployed site keeps showing samples until that
configuration is updated, for example by publishing again.

The storefront polls every five seconds while the page is visible and the state is `unbound` or
`connecting`, and refreshes on mount and focus. A failed refresh immediately hides samples, without
automatic request retries. While the last response was `connecting`, refresh failures keep the
connecting state for up to 60 seconds, so a host restart during connection does not flash an
error. Polling continues, and a longer failure reports the error. Aborted configuration requests cannot overwrite newer responses.
While the page is visible and the state is `unbound` or `connecting`, the storefront polls every
five seconds for five minutes after the page loads, then every minute, and stops thirty minutes
after load. It also refreshes on mount and focus, and reloading or retrying starts polling again. A
page still `connecting` when polling stops shows an error with a retry button, so finish connecting
well within thirty minutes. A failed refresh immediately hides
samples, without automatic request retries. While the last response was `connecting`, refresh
failures keep the connecting state for up to 60 seconds, so a host restart during connection does
not flash an error. This holds only in the first five minutes; after that, polls are a minute
apart, and the first failure reports the error. Polling continues, and a longer failure reports
the error. Aborted configuration requests cannot overwrite newer responses.
Release server and client support before using the `sampleProducts` prop. No new endpoint is required.
57 changes: 53 additions & 4 deletions packages/commerce-storefront/src/commerce-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,11 @@ type PublicConfiguration =
| ({ state: 'ready' } & Pick<StorefrontConfig, 'cartScope' | 'currencyCode'>);

const CONNECTING_FAILURE_GRACE_MS = 60_000;
/** Polls every five seconds for five minutes after the page loads, then every minute, and stops at thirty. */
const FAST_POLL_FOR_MS = 5 * 60_000;
const STOP_POLLING_AT_MS = 30 * 60_000;
/** A failed retry keeps showing connecting this long so the click visibly does something. */
const RETRY_FEEDBACK_MS = 1_000;

/** `locale` as a canonical tag, or `en-US` when it is not a valid one, so one bad prop cannot break every price. */
function resolveLocale(locale: string | undefined): string {
Expand Down Expand Up @@ -230,6 +235,15 @@ export function CommerceProvider({
onEvent,
}: CommerceProviderProps): ReactElement {
const resolvedLocale: string = useMemo(() => resolveLocale(locale), [locale]);
// Polling is timed from load, and a retry or a change in the reported state starts it over.
const [pollingSince, setPollingSince] = useState(Date.now);
const [pollingStopped, setPollingStopped] = useState<boolean>(false);
const [retrying, setRetrying] = useState<boolean>(false);
useEffect(() => {
setPollingStopped(false);
const timeout = setTimeout(() => setPollingStopped(true), pollingSince + STOP_POLLING_AT_MS - Date.now());
return () => clearTimeout(timeout);
}, [pollingSince]);
const config = useQuery({
retry: false,
queryKey: ['commerce', 'configuration'],
Expand All @@ -238,10 +252,22 @@ export function CommerceProvider({
refetchOnMount: 'always',
refetchOnWindowFocus: 'always',
// Errors after an unbound response also need recovery; stale data never grants eligibility.
refetchInterval: (query) =>
query.state.data?.state === 'unbound' || query.state.data?.state === 'connecting' ? 5_000 : false,
// A site can stay unbound for days, so polling ends; reloading the page or retrying starts it again.
refetchInterval: (query) => {
const waiting = query.state.data?.state === 'unbound' || query.state.data?.state === 'connecting';
const elapsed = Date.now() - pollingSince;
if (!waiting || elapsed >= STOP_POLLING_AT_MS) return false;
return elapsed < FAST_POLL_FOR_MS ? 5_000 : 60_000;
},
refetchIntervalInBackground: false,
});
// A store connected after polling stopped (picked up on focus) gets a fresh window.
const [reported, setReported] = useState(config.data?.state);
if (reported !== config.data?.state) {
setReported(config.data?.state);
setPollingSince(Date.now());
setPollingStopped(false);
}
// Hosts may restart while connecting a store. Keep the connecting state through brief
// refresh failures; it never shows samples, and polling continues until the grace ends.
const holdConnecting: boolean =
Expand All @@ -250,6 +276,11 @@ export function CommerceProvider({
config.errorUpdatedAt - config.dataUpdatedAt < CONNECTING_FAILURE_GRACE_MS;
let connection: CommerceContextValue['connection'] = config.data?.state ?? 'loading';
if (config.isError) connection = holdConnecting ? 'connecting' : 'error';
// Nothing refreshes a connecting page once polling stops, so offer a retry instead of waiting forever.
const stalled: boolean = pollingStopped && connection === 'connecting';
if (stalled) connection = 'error';
// Retrying only ever shows connecting; readiness still comes from the refreshed response.
if (retrying) connection = 'connecting';
return (
<BoundCommerceProvider
config={{
Expand All @@ -261,9 +292,27 @@ export function CommerceProvider({
checkoutSuccessPath,
}}
connection={connection}
connectionError={connection === 'error' ? message(config.error) : null}
connectionError={
connection !== 'error'
? null
: stalled
? 'The store is taking too long to connect.'
: message(config.error)
}
retryConnection={() => {
void config.refetch();
const started = Date.now();
setPollingSince(started);
// Only an error shows retry feedback; changing a ready or unbound connection would reset the cart.
if (connection !== 'error') {
void config.refetch();
return;
}
setRetrying(true);
void config.refetch().then(async (result) => {
if (result.isError)
await new Promise((resolve) => setTimeout(resolve, started + RETRY_FEEDBACK_MS - Date.now()));
setRetrying(false);
});
}}
theme={theme}
getSwatch={getSwatch}
Expand Down
164 changes: 164 additions & 0 deletions packages/commerce-storefront/src/storefront.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,170 @@ describe('sample products before a store is connected', () => {
});
});

describe('bounded polling', () => {
const minutes = (count: number) => count * 60_000;
const configReads = (api: ReturnType<typeof mockApi>) =>
api.mock.calls.filter(([path]) => String(path).endsWith('/config')).length;
async function advance(ms: number) {
await act(async () => {
await vi.advanceTimersByTimeAsync(ms);
});
}

it('backs off after five minutes and stops at thirty, including after a failed refresh', async () => {
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval', 'Date'] });
try {
const api = mockApi(() => response({ state: 'unbound' }));
const view = mount(<Catalog sampleProducts />);
await screen.findByRole('button', { name: 'Add Product 1 to sample cart' });
await advance(minutes(5));
expect(configReads(api)).toBe(61);
await advance(minutes(5));
expect(configReads(api)).toBe(66);
api.mockImplementation(async () => response({ error: 'Host state unavailable' }, 503));
await advance(minutes(20));
await vi.waitFor(() => expect(view.context().connection).toBe('error'));
const read = configReads(api);
await advance(minutes(60 * 24));
expect(configReads(api)).toBe(read);
} finally {
vi.useRealTimers();
}
});

it('picks up a connected store while backed off', async () => {
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval', 'Date'] });
try {
let config: unknown = { state: 'unbound' };
mockApi((path) =>
path.endsWith('/config') ? response(config) : response({ skuGroups: { edges: [] } }),
);
const view = mount(<Catalog sampleProducts />);
await screen.findByRole('button', { name: 'Add Product 1 to sample cart' });
await advance(minutes(20));
config = { state: 'ready', ...configuration };
await advance(minutes(1));
await vi.waitFor(() => expect(view.context().connection).toBe('ready'));
await screen.findByText('No products available.');
} finally {
vi.useRealTimers();
}
});

it('offers a retry when the store is still connecting after polling stops', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date'] });
try {
let config: unknown = { state: 'connecting' };
const api = mockApi((path) =>
path.endsWith('/config') ? response(config) : response({ skuGroups: { edges: [] } }),
);
const view = mount(<Catalog sampleProducts />);
await vi.waitFor(() => expect(view.context().connection).toBe('connecting'));
await advance(minutes(30));
await vi.waitFor(() => expect(view.context().connection).toBe('error'));
expect(screen.getByText('The store is taking too long to connect.')).toBeVisible();
// A retry shows connecting again and starts a new polling period.
const read = configReads(api);
fireEvent.click(screen.getByRole('button', { name: 'Retry connection' }));
await vi.waitFor(() => expect(view.context().connection).toBe('connecting'));
expect(screen.getByText('Connecting to the store…')).toBeVisible();
expect(configReads(api)).toBe(read + 1);
await advance(5_000);
expect(configReads(api)).toBe(read + 2);
await advance(minutes(30));
await vi.waitFor(() => expect(view.context().connection).toBe('error'));
config = { state: 'ready', ...configuration };
fireEvent.click(screen.getByRole('button', { name: 'Retry connection' }));
await vi.waitFor(() => expect(view.context().connection).toBe('ready'));
} finally {
vi.useRealTimers();
}
});

it('shows connecting for at least a second when a retry fails', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date'] });
try {
const api = mockApi(async () => response({ error: 'Store offline' }, 503));
const view = mount(<Catalog />);
await vi.waitFor(() => expect(screen.getByText('Store offline')).toBeVisible());
const read = configReads(api);
fireEvent.click(screen.getByRole('button', { name: 'Retry connection' }));
await vi.waitFor(() => expect(configReads(api)).toBe(read + 1));
await advance(900);
expect(view.context().connection).toBe('connecting');
expect(screen.getByText('Connecting to the store…')).toBeVisible();
expect(screen.queryByText('Store offline')).toBeNull();
await advance(100);
await vi.waitFor(() => expect(screen.getByText('Store offline')).toBeVisible());
expect(view.context().connection).toBe('error');
} finally {
vi.useRealTimers();
}
});

it('keeps a connected cart when retrying without an error', async () => {
localStorage.setItem(storageKey, 'cart-1');
const api = mockApi((path) =>
path.endsWith('/config') ? response(configuration) : response({ cart: cart() }),
);
const view = mount();
await connected(view);
const loaded = view.context().cart;
const reads = api.mock.calls.filter(([path]) => String(path).includes('/cart')).length;
act(() => view.context().retryConnection());
expect(view.context().connection).toBe('ready');
await waitFor(() => expect(configReads(api)).toBe(2));
expect(view.context().cart).toBe(loaded);
expect(api.mock.calls.filter(([path]) => String(path).includes('/cart')).length).toBe(reads);
});

it('gives a store connected after polling stopped a fresh window', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval', 'Date'] });
try {
let config: unknown = { state: 'unbound' };
const api = mockApi((path) =>
path.endsWith('/config') ? response(config) : response({ skuGroups: { edges: [] } }),
);
const view = mount(<Catalog sampleProducts />);
await vi.waitFor(() => expect(view.context().connection).toBe('unbound'));
await advance(minutes(60));
// A focus refresh after polling stopped finds the store connecting.
config = { state: 'connecting' };
await act(async () => {
await view.client.refetchQueries({ queryKey: ['commerce', 'configuration'] });
});
await vi.waitFor(() => expect(view.context().connection).toBe('connecting'));
expect(screen.queryByText('The store is taking too long to connect.')).toBeNull();
const read = configReads(api);
await advance(5_000);
expect(configReads(api)).toBe(read + 1);
config = { state: 'ready', ...configuration };
await advance(5_000);
await vi.waitFor(() => expect(view.context().connection).toBe('ready'));
} finally {
vi.useRealTimers();
}
});

it('polls again after a reload once polling has stopped', async () => {
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval', 'Date'] });
try {
const api = mockApi(() => response({ state: 'unbound' }));
const view = mount(<Catalog sampleProducts />);
await screen.findByRole('button', { name: 'Add Product 1 to sample cart' });
await advance(minutes(60));
view.unmount();
const stopped = configReads(api);
mount(<Catalog sampleProducts />);
await vi.waitFor(() => expect(configReads(api)).toBe(stopped + 1));
await advance(5_000);
expect(configReads(api)).toBe(stopped + 2);
} finally {
vi.useRealTimers();
}
});
});

describe('connection state transitions', () => {
it('preserves samples across routed pages but resets when the storefront is remounted', async () => {
mockApi(() => response({ state: 'unbound' }));
Expand Down
Loading