From 79fdf2a91c62723c0b5e13ce1810bc9b30acea1a Mon Sep 17 00:00:00 2001 From: Scott Bolinger Date: Fri, 23 Oct 2026 10:30:59 -0700 Subject: [PATCH 1/8] Bound storefront config polling while unbound or connecting A site with no connected store reports `unbound` until it is republished, so every visible tab requested /config every five seconds indefinitely, and failed refreshes kept the interval running. Poll every five seconds for two minutes, then every 30 seconds, and stop after fifteen minutes. `connecting` should end soon, so it polls fast for five minutes and stops after 30. Each limit counts from when the page first saw that state. Mount and visibility refetches still recover, and visibility starts a new polling period. Co-Authored-By: Claude Opus 5.5 --- .changeset/bounded-config-polling.md | 5 + packages/commerce-storefront/README.md | 7 +- .../commerce-storefront/docs/server-api.md | 9 +- .../src/commerce-provider.tsx | 40 ++++- .../src/storefront.test.tsx | 159 +++++++++++++++++- 5 files changed, 209 insertions(+), 11 deletions(-) create mode 100644 .changeset/bounded-config-polling.md diff --git a/.changeset/bounded-config-polling.md b/.changeset/bounded-config-polling.md new file mode 100644 index 00000000..e87e8045 --- /dev/null +++ b/.changeset/bounded-config-polling.md @@ -0,0 +1,5 @@ +--- +'@godaddy/gd-commerce-storefront': minor +--- + +Configuration polling now ends. While the server reports `unbound`, the provider polls `/api/commerce/config` every five seconds for two minutes, then every 30 seconds, and stops after fifteen minutes, including when refreshes fail. `connecting` follows the same pattern, polling fast for five minutes and stopping after 30. Before, both states polled every five seconds for as long as the page stayed visible, so a site without a store sent a request every five seconds indefinitely. Mount, the page becoming visible and `retryConnection()` still refresh configuration, and the page becoming visible starts a new polling period. Samples, the connecting failure grace and background-tab behavior are unchanged. diff --git a/packages/commerce-storefront/README.md b/packages/commerce-storefront/README.md index c9d808b6..f18f92a5 100644 --- a/packages/commerce-storefront/README.md +++ b/packages/commerce-storefront/README.md @@ -525,8 +525,11 @@ 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 +The provider reads configuration on mount and when the page becomes visible. While the page is +visible and the state is `unbound`, it also polls every five seconds for two minutes, then every 30 +seconds until fifteen minutes have passed. `connecting` polls fast for five minutes and stops after 30 +minutes. Each limit counts from when the page first saw that state, and failed refreshes count toward +it. Showing the page again, or remounting, starts a new polling period. 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 diff --git a/packages/commerce-storefront/docs/server-api.md b/packages/commerce-storefront/docs/server-api.md index de0f8b00..7ea1cda8 100644 --- a/packages/commerce-storefront/docs/server-api.md +++ b/packages/commerce-storefront/docs/server-api.md @@ -134,9 +134,12 @@ 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 +While the page is visible and the state is `unbound`, the storefront polls every five seconds for +two minutes, then every 30 seconds, and stops after fifteen minutes. `connecting` polls fast for five +minutes and stops after 30. Each page refreshes on mount and when it becomes visible, which also starts +a new polling period. A host that connects a store in a page that stays visible, such as a preview +iframe, should report `connecting` promptly or reload the page once the store is ready. 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. +error. Polling continues within its limit, 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. diff --git a/packages/commerce-storefront/src/commerce-provider.tsx b/packages/commerce-storefront/src/commerce-provider.tsx index 3ca83c18..13e0482c 100644 --- a/packages/commerce-storefront/src/commerce-provider.tsx +++ b/packages/commerce-storefront/src/commerce-provider.tsx @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query'; +import { focusManager, type Query, useQuery } from '@tanstack/react-query'; import { type CSSProperties, createContext, @@ -169,6 +169,19 @@ type PublicConfiguration = const CONNECTING_FAILURE_GRACE_MS = 60_000; +type WaitingState = 'unbound' | 'connecting'; +/** + * How long to poll `/config` in a waiting state, timed from when the page first saw it: every `FAST_POLL_MS` for + * `fastFor`, then every `SLOW_POLL_MS` until `stopAfter`. A site may stay `unbound` indefinitely, so polling must + * end; mount and focus refetches recover afterwards. `connecting` should end soon, so it is followed for longer. + */ +const POLLING: Record = { + unbound: { fastFor: 2 * 60_000, stopAfter: 15 * 60_000 }, + connecting: { fastFor: 5 * 60_000, stopAfter: 30 * 60_000 }, +}; +const FAST_POLL_MS = 5_000; +const SLOW_POLL_MS = 30_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 { if (!locale) return 'en-US'; @@ -230,6 +243,26 @@ export function CommerceProvider({ onEvent, }: CommerceProviderProps): ReactElement { const resolvedLocale: string = useMemo(() => resolveLocale(locale), [locale]); + // The waiting state this page is polling for and when it was first seen. Showing the page again starts over. + const waiting = useRef<{ state: WaitingState; since: number } | null>(null); + useEffect( + () => + focusManager.subscribe((focused: boolean) => { + if (focused) waiting.current = null; + }), + [], + ); + const refetchInterval = useCallback((query: Query): number | false => { + const state = query.state.data?.state; + if (state !== 'unbound' && state !== 'connecting') { + waiting.current = null; + return false; + } + if (waiting.current?.state !== state) waiting.current = { state, since: Date.now() }; + const elapsed: number = Date.now() - waiting.current.since; + if (elapsed < POLLING[state].fastFor) return FAST_POLL_MS; + return elapsed < POLLING[state].stopAfter ? SLOW_POLL_MS : false; + }, []); const config = useQuery({ retry: false, queryKey: ['commerce', 'configuration'], @@ -237,9 +270,8 @@ export function CommerceProvider({ staleTime: 30_000, 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, + // Errors after a waiting response keep polling within the same limit; stale data never grants eligibility. + refetchInterval, refetchIntervalInBackground: false, }); // Hosts may restart while connecting a store. Keep the connecting state through brief diff --git a/packages/commerce-storefront/src/storefront.test.tsx b/packages/commerce-storefront/src/storefront.test.tsx index 2e53b0de..c7cb61f7 100644 --- a/packages/commerce-storefront/src/storefront.test.tsx +++ b/packages/commerce-storefront/src/storefront.test.tsx @@ -39,8 +39,12 @@ function mockApi(handler: (path: string, init?: RequestInit) => Response | Promi vi.stubGlobal('fetch', fn); return fn; } -function mount(children?: ReactNode, path = '/shop', checkoutSuccessPath?: string) { - const client = new QueryClient({ defaultOptions: { queries: { gcTime: 0 } } }); +function mount( + children?: ReactNode, + path = '/shop', + checkoutSuccessPath?: string, + client = new QueryClient({ defaultOptions: { queries: { gcTime: 0 } } }), +) { let context: CommerceContextValue; function Observe() { context = useCommerce(); @@ -734,6 +738,157 @@ describe('sample products before a store is connected', () => { }); }); +describe('bounded polling while waiting for a store', () => { + const minutes = (count: number) => count * 60_000; + const configReads = (api: ReturnType) => + api.mock.calls.filter(([path]) => String(path).endsWith('/config')).length; + async function advance(ms: number) { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms); + }); + } + function showPage() { + act(() => { + window.dispatchEvent(new Event('visibilitychange')); + }); + } + function withTimers(test: () => Promise) { + return async () => { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval', 'Date'] }); + try { + await test(); + } finally { + vi.useRealTimers(); + } + }; + } + + it( + 'stops polling an unbound store after fifteen minutes', + withTimers(async () => { + const api = mockApi(() => response({ state: 'unbound' })); + const view = mount(); + await screen.findByRole('button', { name: 'Add Product 1 to sample cart' }); + await advance(minutes(2)); + // Every five seconds for two minutes, then every thirty seconds. + expect(configReads(api)).toBe(1 + 24); + await advance(minutes(13)); + const read = configReads(api); + expect(read).toBeLessThanOrEqual(1 + 24 + 26); + await advance(minutes(60)); + expect(configReads(api)).toBe(read); + expect(view.context().connection).toBe('unbound'); + expect(screen.getByText('Product 1')).toBeVisible(); + }), + ); + + it( + 'stops polling after failed refreshes of an unbound store', + withTimers(async () => { + const api = mockApi(() => response({ state: 'unbound' })); + const view = mount(); + await screen.findByRole('button', { name: 'Add Product 1 to sample cart' }); + api.mockImplementation(async () => response({ error: 'Host state unavailable' }, 503)); + await advance(5_000); + await vi.waitFor(() => expect(view.context().connection).toBe('error')); + expect(document.querySelector('[data-commerce-source="sample"]')).toBeNull(); + await advance(minutes(15)); + const read = configReads(api); + await advance(minutes(60)); + expect(configReads(api)).toBe(read); + expect(view.context().connection).toBe('error'); + }), + ); + + it( + 'picks up a connected store while polling slows down', + withTimers(async () => { + let config: unknown = { state: 'unbound' }; + mockApi((path) => + path.endsWith('/config') ? response(config) : response({ skuGroups: { edges: [] } }), + ); + const view = mount(); + await screen.findByRole('button', { name: 'Add Product 1 to sample cart' }); + await advance(minutes(10)); + config = { state: 'ready', ...configuration }; + await advance(30_000); + await vi.waitFor(() => expect(view.context().connection).toBe('ready')); + await screen.findByText('No products available.'); + }), + ); + + it( + 'recovers on focus after polling stops and polls again while still unbound', + withTimers(async () => { + let config: unknown = { state: 'unbound' }; + const api = mockApi((path) => + path.endsWith('/config') ? response(config) : response({ skuGroups: { edges: [] } }), + ); + const view = mount(); + await screen.findByRole('button', { name: 'Add Product 1 to sample cart' }); + await advance(minutes(20)); + const stopped = configReads(api); + showPage(); + await vi.waitFor(() => expect(configReads(api)).toBe(stopped + 1)); + await advance(5_000); + expect(configReads(api)).toBe(stopped + 2); + config = { state: 'ready', ...configuration }; + await advance(minutes(20)); + expect(configReads(api)).toBe(stopped + 3); + await vi.waitFor(() => expect(view.context().connection).toBe('ready')); + await advance(minutes(20)); + config = { state: 'unbound' }; + showPage(); + await vi.waitFor(() => expect(configReads(api)).toBe(stopped + 4)); + }), + ); + + it( + 'recovers on mount after polling stops', + withTimers(async () => { + let config: unknown = { state: 'unbound' }; + const api = mockApi((path) => + path.endsWith('/config') ? response(config) : response({ skuGroups: { edges: [] } }), + ); + const view = mount(); + await screen.findByRole('button', { name: 'Add Product 1 to sample cart' }); + await advance(minutes(20)); + const stopped = configReads(api); + config = { state: 'ready', ...configuration }; + await advance(minutes(5)); + expect(configReads(api)).toBe(stopped); + expect(view.context().connection).toBe('unbound'); + view.unmount(); + const next = mount(, '/shop', undefined, view.client); + await vi.waitFor(() => expect(next.context().connection).toBe('ready')); + expect(configReads(api)).toBe(stopped + 1); + }), + ); + + it( + 'polls a connecting store for longer than an unbound one, then stops', + withTimers(async () => { + let config: unknown = { state: 'unbound' }; + const api = mockApi(() => response(config)); + const view = mount(); + await screen.findByRole('button', { name: 'Add Product 1 to sample cart' }); + await advance(minutes(10)); + config = { state: 'connecting' }; + await advance(30_000); + await vi.waitFor(() => expect(view.context().connection).toBe('connecting')); + // The connecting limit starts when the state is first seen, not when unbound was. + const connected = configReads(api); + await advance(minutes(5)); + expect(configReads(api)).toBe(connected + 60); + await advance(minutes(25)); + const read = configReads(api); + await advance(minutes(60)); + expect(configReads(api)).toBe(read); + expect(view.context().connection).toBe('connecting'); + }), + ); +}); + describe('connection state transitions', () => { it('preserves samples across routed pages but resets when the storefront is remounted', async () => { mockApi(() => response({ state: 'unbound' })); From 5c23f19d5670a98d26e1a97975a5c917a66aab91 Mon Sep 17 00:00:00 2001 From: Scott Bolinger Date: Fri, 23 Oct 2026 10:39:47 -0700 Subject: [PATCH 2/8] Replace the polling schedule with a request cap Stop polling after a fixed number of /config results, failures included, counted by React Query: 180 (about fifteen minutes) unbound and 360 (about thirty) connecting. This bounds polling with a much smaller change than the backoff schedule and visibility tracking it replaces. Co-Authored-By: Claude Opus 5.5 --- .changeset/bounded-config-polling.md | 2 +- packages/commerce-storefront/README.md | 8 +- .../commerce-storefront/docs/server-api.md | 9 +- .../src/commerce-provider.tsx | 44 +---- .../src/storefront.test.tsx | 183 +++++------------- 5 files changed, 63 insertions(+), 183 deletions(-) diff --git a/.changeset/bounded-config-polling.md b/.changeset/bounded-config-polling.md index e87e8045..9565874b 100644 --- a/.changeset/bounded-config-polling.md +++ b/.changeset/bounded-config-polling.md @@ -2,4 +2,4 @@ '@godaddy/gd-commerce-storefront': minor --- -Configuration polling now ends. While the server reports `unbound`, the provider polls `/api/commerce/config` every five seconds for two minutes, then every 30 seconds, and stops after fifteen minutes, including when refreshes fail. `connecting` follows the same pattern, polling fast for five minutes and stopping after 30. Before, both states polled every five seconds for as long as the page stayed visible, so a site without a store sent a request every five seconds indefinitely. Mount, the page becoming visible and `retryConnection()` still refresh configuration, and the page becoming visible starts a new polling period. Samples, the connecting failure grace and background-tab behavior are unchanged. +Configuration polling now stops instead of continuing every five seconds for as long as the page is visible: after about fifteen minutes (180 requests) while the server reports `unbound`, and thirty minutes (360) while `connecting`. Failed requests count toward the limit. Mount, focus and `retryConnection()` still refresh configuration. diff --git a/packages/commerce-storefront/README.md b/packages/commerce-storefront/README.md index f18f92a5..7a910340 100644 --- a/packages/commerce-storefront/README.md +++ b/packages/commerce-storefront/README.md @@ -525,11 +525,9 @@ 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 when the page becomes visible. While the page is -visible and the state is `unbound`, it also polls every five seconds for two minutes, then every 30 -seconds until fifteen minutes have passed. `connecting` polls fast for five minutes and stops after 30 -minutes. Each limit counts from when the page first saw that state, and failed refreshes count toward -it. Showing the page again, or remounting, starts a new polling period. The first failed refresh hides samples and +The provider reads configuration on mount and focus, and every five seconds while the page is +visible and the state is `unbound` or `connecting`. Polling stops after about fifteen minutes +unbound or thirty connecting, failures included; mount and focus still refresh. 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 diff --git a/packages/commerce-storefront/docs/server-api.md b/packages/commerce-storefront/docs/server-api.md index 7ea1cda8..ca71378b 100644 --- a/packages/commerce-storefront/docs/server-api.md +++ b/packages/commerce-storefront/docs/server-api.md @@ -134,12 +134,9 @@ 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. -While the page is visible and the state is `unbound`, the storefront polls every five seconds for -two minutes, then every 30 seconds, and stops after fifteen minutes. `connecting` polls fast for five -minutes and stops after 30. Each page refreshes on mount and when it becomes visible, which also starts -a new polling period. A host that connects a store in a page that stays visible, such as a preview -iframe, should report `connecting` promptly or reload the page once the store is ready. A failed refresh immediately hides samples, without +The storefront polls every five seconds while the page is visible and the state is `unbound` or +`connecting`, and refreshes on mount and focus. Polling stops after about fifteen minutes unbound or thirty connecting. 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 within its limit, and a longer failure reports the error. Aborted configuration requests cannot overwrite newer responses. +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. diff --git a/packages/commerce-storefront/src/commerce-provider.tsx b/packages/commerce-storefront/src/commerce-provider.tsx index 13e0482c..67c199b1 100644 --- a/packages/commerce-storefront/src/commerce-provider.tsx +++ b/packages/commerce-storefront/src/commerce-provider.tsx @@ -1,4 +1,4 @@ -import { focusManager, type Query, useQuery } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { type CSSProperties, createContext, @@ -168,19 +168,11 @@ type PublicConfiguration = | ({ state: 'ready' } & Pick); const CONNECTING_FAILURE_GRACE_MS = 60_000; - -type WaitingState = 'unbound' | 'connecting'; /** - * How long to poll `/config` in a waiting state, timed from when the page first saw it: every `FAST_POLL_MS` for - * `fastFor`, then every `SLOW_POLL_MS` until `stopAfter`. A site may stay `unbound` indefinitely, so polling must - * end; mount and focus refetches recover afterwards. `connecting` should end soon, so it is followed for longer. + * Polls at five seconds stop after about fifteen minutes unbound or thirty connecting, counting failures. A site can + * stay unbound indefinitely; mount and focus still refresh. */ -const POLLING: Record = { - unbound: { fastFor: 2 * 60_000, stopAfter: 15 * 60_000 }, - connecting: { fastFor: 5 * 60_000, stopAfter: 30 * 60_000 }, -}; -const FAST_POLL_MS = 5_000; -const SLOW_POLL_MS = 30_000; +const POLL_LIMIT = { unbound: 180, connecting: 360 } as const; /** `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 { @@ -243,26 +235,6 @@ export function CommerceProvider({ onEvent, }: CommerceProviderProps): ReactElement { const resolvedLocale: string = useMemo(() => resolveLocale(locale), [locale]); - // The waiting state this page is polling for and when it was first seen. Showing the page again starts over. - const waiting = useRef<{ state: WaitingState; since: number } | null>(null); - useEffect( - () => - focusManager.subscribe((focused: boolean) => { - if (focused) waiting.current = null; - }), - [], - ); - const refetchInterval = useCallback((query: Query): number | false => { - const state = query.state.data?.state; - if (state !== 'unbound' && state !== 'connecting') { - waiting.current = null; - return false; - } - if (waiting.current?.state !== state) waiting.current = { state, since: Date.now() }; - const elapsed: number = Date.now() - waiting.current.since; - if (elapsed < POLLING[state].fastFor) return FAST_POLL_MS; - return elapsed < POLLING[state].stopAfter ? SLOW_POLL_MS : false; - }, []); const config = useQuery({ retry: false, queryKey: ['commerce', 'configuration'], @@ -270,8 +242,12 @@ export function CommerceProvider({ staleTime: 30_000, refetchOnMount: 'always', refetchOnWindowFocus: 'always', - // Errors after a waiting response keep polling within the same limit; stale data never grants eligibility. - refetchInterval, + // Errors after an unbound response also need recovery; stale data never grants eligibility. + refetchInterval: ({ state }) => + (state.data?.state === 'unbound' || state.data?.state === 'connecting') && + state.dataUpdateCount + state.errorUpdateCount < POLL_LIMIT[state.data.state] + ? 5_000 + : false, refetchIntervalInBackground: false, }); // Hosts may restart while connecting a store. Keep the connecting state through brief diff --git a/packages/commerce-storefront/src/storefront.test.tsx b/packages/commerce-storefront/src/storefront.test.tsx index c7cb61f7..2f85ccfc 100644 --- a/packages/commerce-storefront/src/storefront.test.tsx +++ b/packages/commerce-storefront/src/storefront.test.tsx @@ -39,12 +39,8 @@ function mockApi(handler: (path: string, init?: RequestInit) => Response | Promi vi.stubGlobal('fetch', fn); return fn; } -function mount( - children?: ReactNode, - path = '/shop', - checkoutSuccessPath?: string, - client = new QueryClient({ defaultOptions: { queries: { gcTime: 0 } } }), -) { +function mount(children?: ReactNode, path = '/shop', checkoutSuccessPath?: string) { + const client = new QueryClient({ defaultOptions: { queries: { gcTime: 0 } } }); let context: CommerceContextValue; function Observe() { context = useCommerce(); @@ -738,155 +734,68 @@ describe('sample products before a store is connected', () => { }); }); -describe('bounded polling while waiting for a store', () => { - const minutes = (count: number) => count * 60_000; +describe('bounded polling', () => { const configReads = (api: ReturnType) => api.mock.calls.filter(([path]) => String(path).endsWith('/config')).length; - async function advance(ms: number) { - await act(async () => { - await vi.advanceTimersByTimeAsync(ms); - }); - } - function showPage() { - act(() => { - window.dispatchEvent(new Event('visibilitychange')); - }); - } - function withTimers(test: () => Promise) { - return async () => { - vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval', 'Date'] }); - try { - await test(); - } finally { - vi.useRealTimers(); - } - }; - } - it( - 'stops polling an unbound store after fifteen minutes', - withTimers(async () => { - const api = mockApi(() => response({ state: 'unbound' })); - const view = mount(); - await screen.findByRole('button', { name: 'Add Product 1 to sample cart' }); - await advance(minutes(2)); - // Every five seconds for two minutes, then every thirty seconds. - expect(configReads(api)).toBe(1 + 24); - await advance(minutes(13)); - const read = configReads(api); - expect(read).toBeLessThanOrEqual(1 + 24 + 26); - await advance(minutes(60)); - expect(configReads(api)).toBe(read); - expect(view.context().connection).toBe('unbound'); - expect(screen.getByText('Product 1')).toBeVisible(); - }), - ); - - it( - 'stops polling after failed refreshes of an unbound store', - withTimers(async () => { + it('stops polling an unbound store after fifteen minutes, including after a failed refresh', async () => { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }); + try { const api = mockApi(() => response({ state: 'unbound' })); const view = mount(); await screen.findByRole('button', { name: 'Add Product 1 to sample cart' }); api.mockImplementation(async () => response({ error: 'Host state unavailable' }, 503)); - await advance(5_000); - await vi.waitFor(() => expect(view.context().connection).toBe('error')); - expect(document.querySelector('[data-commerce-source="sample"]')).toBeNull(); - await advance(minutes(15)); - const read = configReads(api); - await advance(minutes(60)); - expect(configReads(api)).toBe(read); + await act(async () => { + await vi.advanceTimersByTimeAsync(15 * 60_000); + }); + expect(configReads(api)).toBe(180); + await act(async () => { + await vi.advanceTimersByTimeAsync(60 * 60_000); + }); + expect(configReads(api)).toBe(180); expect(view.context().connection).toBe('error'); - }), - ); + } finally { + vi.useRealTimers(); + } + }); - it( - 'picks up a connected store while polling slows down', - withTimers(async () => { - let config: unknown = { state: 'unbound' }; - mockApi((path) => - path.endsWith('/config') ? response(config) : response({ skuGroups: { edges: [] } }), - ); - const view = mount(); - await screen.findByRole('button', { name: 'Add Product 1 to sample cart' }); - await advance(minutes(10)); - config = { state: 'ready', ...configuration }; - await advance(30_000); - await vi.waitFor(() => expect(view.context().connection).toBe('ready')); - await screen.findByText('No products available.'); - }), - ); + it('stops polling a connecting store after thirty minutes', async () => { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }); + try { + const api = mockApi(() => response({ state: 'connecting' })); + mount(); + await act(async () => { + await vi.advanceTimersByTimeAsync(60 * 60_000); + }); + expect(configReads(api)).toBe(360); + } finally { + vi.useRealTimers(); + } + }); - it( - 'recovers on focus after polling stops and polls again while still unbound', - withTimers(async () => { + it('picks up a connected store on focus after polling stops', async () => { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }); + try { let config: unknown = { state: 'unbound' }; const api = mockApi((path) => path.endsWith('/config') ? response(config) : response({ skuGroups: { edges: [] } }), ); const view = mount(); await screen.findByRole('button', { name: 'Add Product 1 to sample cart' }); - await advance(minutes(20)); - const stopped = configReads(api); - showPage(); - await vi.waitFor(() => expect(configReads(api)).toBe(stopped + 1)); - await advance(5_000); - expect(configReads(api)).toBe(stopped + 2); + await act(async () => { + await vi.advanceTimersByTimeAsync(20 * 60_000); + }); + expect(configReads(api)).toBe(180); config = { state: 'ready', ...configuration }; - await advance(minutes(20)); - expect(configReads(api)).toBe(stopped + 3); + act(() => { + window.dispatchEvent(new Event('visibilitychange')); + }); await vi.waitFor(() => expect(view.context().connection).toBe('ready')); - await advance(minutes(20)); - config = { state: 'unbound' }; - showPage(); - await vi.waitFor(() => expect(configReads(api)).toBe(stopped + 4)); - }), - ); - - it( - 'recovers on mount after polling stops', - withTimers(async () => { - let config: unknown = { state: 'unbound' }; - const api = mockApi((path) => - path.endsWith('/config') ? response(config) : response({ skuGroups: { edges: [] } }), - ); - const view = mount(); - await screen.findByRole('button', { name: 'Add Product 1 to sample cart' }); - await advance(minutes(20)); - const stopped = configReads(api); - config = { state: 'ready', ...configuration }; - await advance(minutes(5)); - expect(configReads(api)).toBe(stopped); - expect(view.context().connection).toBe('unbound'); - view.unmount(); - const next = mount(, '/shop', undefined, view.client); - await vi.waitFor(() => expect(next.context().connection).toBe('ready')); - expect(configReads(api)).toBe(stopped + 1); - }), - ); - - it( - 'polls a connecting store for longer than an unbound one, then stops', - withTimers(async () => { - let config: unknown = { state: 'unbound' }; - const api = mockApi(() => response(config)); - const view = mount(); - await screen.findByRole('button', { name: 'Add Product 1 to sample cart' }); - await advance(minutes(10)); - config = { state: 'connecting' }; - await advance(30_000); - await vi.waitFor(() => expect(view.context().connection).toBe('connecting')); - // The connecting limit starts when the state is first seen, not when unbound was. - const connected = configReads(api); - await advance(minutes(5)); - expect(configReads(api)).toBe(connected + 60); - await advance(minutes(25)); - const read = configReads(api); - await advance(minutes(60)); - expect(configReads(api)).toBe(read); - expect(view.context().connection).toBe('connecting'); - }), - ); + await screen.findByText('No products available.'); + } finally { + vi.useRealTimers(); + } + }); }); describe('connection state transitions', () => { From 4e2d7983edfefaf1438d55d388b12dd6b42873da Mon Sep 17 00:00:00 2001 From: Scott Bolinger Date: Fri, 23 Oct 2026 10:47:16 -0700 Subject: [PATCH 3/8] Back off config polling after load and stop at thirty minutes A site can stay unbound for days, so a request cap or focus-based restart does not fit. Time polling from page load instead: every five seconds for five minutes, then every minute, and stop at thirty minutes. Reloading the page starts it again. Co-Authored-By: Claude Opus 5.5 --- .changeset/bounded-config-polling.md | 2 +- packages/commerce-storefront/README.md | 6 +- .../commerce-storefront/docs/server-api.md | 5 +- .../src/commerce-provider.tsx | 21 +++--- .../src/storefront.test.tsx | 75 ++++++++++--------- 5 files changed, 58 insertions(+), 51 deletions(-) diff --git a/.changeset/bounded-config-polling.md b/.changeset/bounded-config-polling.md index 9565874b..978232a1 100644 --- a/.changeset/bounded-config-polling.md +++ b/.changeset/bounded-config-polling.md @@ -2,4 +2,4 @@ '@godaddy/gd-commerce-storefront': minor --- -Configuration polling now stops instead of continuing every five seconds for as long as the page is visible: after about fifteen minutes (180 requests) while the server reports `unbound`, and thirty minutes (360) while `connecting`. Failed requests count toward the limit. Mount, focus and `retryConnection()` still refresh configuration. +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 starts polling again, and focus and `retryConnection()` still refresh configuration. diff --git a/packages/commerce-storefront/README.md b/packages/commerce-storefront/README.md index 7a910340..67157bf9 100644 --- a/packages/commerce-storefront/README.md +++ b/packages/commerce-storefront/README.md @@ -525,9 +525,9 @@ 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`. Polling stops after about fifteen minutes -unbound or thirty connecting, failures included; mount and focus still refresh. The first failed refresh hides samples and +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 the page starts polling again. 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 diff --git a/packages/commerce-storefront/docs/server-api.md b/packages/commerce-storefront/docs/server-api.md index ca71378b..dc7e8440 100644 --- a/packages/commerce-storefront/docs/server-api.md +++ b/packages/commerce-storefront/docs/server-api.md @@ -134,8 +134,9 @@ 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. Polling stops after about fifteen minutes unbound or thirty connecting. A failed refresh immediately hides samples, without +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 the page starts polling again. 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. diff --git a/packages/commerce-storefront/src/commerce-provider.tsx b/packages/commerce-storefront/src/commerce-provider.tsx index 67c199b1..9a3e6a0e 100644 --- a/packages/commerce-storefront/src/commerce-provider.tsx +++ b/packages/commerce-storefront/src/commerce-provider.tsx @@ -168,11 +168,9 @@ type PublicConfiguration = | ({ state: 'ready' } & Pick); const CONNECTING_FAILURE_GRACE_MS = 60_000; -/** - * Polls at five seconds stop after about fifteen minutes unbound or thirty connecting, counting failures. A site can - * stay unbound indefinitely; mount and focus still refresh. - */ -const POLL_LIMIT = { unbound: 180, connecting: 360 } as const; +/** 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; /** `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 { @@ -235,6 +233,7 @@ export function CommerceProvider({ onEvent, }: CommerceProviderProps): ReactElement { const resolvedLocale: string = useMemo(() => resolveLocale(locale), [locale]); + const [mountedAt] = useState(Date.now); const config = useQuery({ retry: false, queryKey: ['commerce', 'configuration'], @@ -243,11 +242,13 @@ export function CommerceProvider({ refetchOnMount: 'always', refetchOnWindowFocus: 'always', // Errors after an unbound response also need recovery; stale data never grants eligibility. - refetchInterval: ({ state }) => - (state.data?.state === 'unbound' || state.data?.state === 'connecting') && - state.dataUpdateCount + state.errorUpdateCount < POLL_LIMIT[state.data.state] - ? 5_000 - : false, + // A site can stay unbound for days, so polling ends; reloading the page starts it again. + refetchInterval: (query) => { + const waiting = query.state.data?.state === 'unbound' || query.state.data?.state === 'connecting'; + const elapsed = Date.now() - mountedAt; + if (!waiting || elapsed >= STOP_POLLING_AT_MS) return false; + return elapsed < FAST_POLL_FOR_MS ? 5_000 : 60_000; + }, refetchIntervalInBackground: false, }); // Hosts may restart while connecting a store. Keep the connecting state through brief diff --git a/packages/commerce-storefront/src/storefront.test.tsx b/packages/commerce-storefront/src/storefront.test.tsx index 2f85ccfc..ca0e80c1 100644 --- a/packages/commerce-storefront/src/storefront.test.tsx +++ b/packages/commerce-storefront/src/storefront.test.tsx @@ -735,67 +735,72 @@ describe('sample products before a store is connected', () => { }); describe('bounded polling', () => { + const minutes = (count: number) => count * 60_000; const configReads = (api: ReturnType) => api.mock.calls.filter(([path]) => String(path).endsWith('/config')).length; + async function advance(ms: number) { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms); + }); + } - it('stops polling an unbound store after fifteen minutes, including after a failed refresh', async () => { - vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }); + 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(); 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 act(async () => { - await vi.advanceTimersByTimeAsync(15 * 60_000); - }); - expect(configReads(api)).toBe(180); - await act(async () => { - await vi.advanceTimersByTimeAsync(60 * 60_000); - }); - expect(configReads(api)).toBe(180); - expect(view.context().connection).toBe('error'); - } finally { - vi.useRealTimers(); - } - }); - - it('stops polling a connecting store after thirty minutes', async () => { - vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }); - try { - const api = mockApi(() => response({ state: 'connecting' })); - mount(); - await act(async () => { - await vi.advanceTimersByTimeAsync(60 * 60_000); - }); - expect(configReads(api)).toBe(360); + 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 on focus after polling stops', async () => { - vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }); + it('picks up a connected store while backed off', async () => { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval', 'Date'] }); try { let config: unknown = { state: 'unbound' }; - const api = mockApi((path) => + mockApi((path) => path.endsWith('/config') ? response(config) : response({ skuGroups: { edges: [] } }), ); const view = mount(); await screen.findByRole('button', { name: 'Add Product 1 to sample cart' }); - await act(async () => { - await vi.advanceTimersByTimeAsync(20 * 60_000); - }); - expect(configReads(api)).toBe(180); + await advance(minutes(20)); config = { state: 'ready', ...configuration }; - act(() => { - window.dispatchEvent(new Event('visibilitychange')); - }); + await advance(minutes(1)); await vi.waitFor(() => expect(view.context().connection).toBe('ready')); await screen.findByText('No products available.'); } 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(); + await screen.findByRole('button', { name: 'Add Product 1 to sample cart' }); + await advance(minutes(60)); + view.unmount(); + const stopped = configReads(api); + mount(); + 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', () => { From c36be18da466fbd0ee3404d1f144a27102b3743d Mon Sep 17 00:00:00 2001 From: Scott Bolinger Date: Fri, 23 Oct 2026 10:53:53 -0700 Subject: [PATCH 4/8] Document connecting after polling stops and the slowed grace A page still connecting at thirty minutes shows the connecting message until focus or reload. Once polls are a minute apart, the 60-second connecting grace has already passed at the next failure, so the error shows at once. Rewrap the edited paragraphs to the surrounding width. Co-Authored-By: Claude Opus 5.5 --- packages/commerce-storefront/README.md | 12 +++++++----- packages/commerce-storefront/docs/server-api.md | 14 +++++++++----- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/commerce-storefront/README.md b/packages/commerce-storefront/README.md index 67157bf9..15c4b23c 100644 --- a/packages/commerce-storefront/README.md +++ b/packages/commerce-storefront/README.md @@ -527,11 +527,13 @@ Sample grids and drawers have the attribute `data-commerce-source="sample"`. 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 the page starts polling again. 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. +then every minute, and stops thirty minutes after load. Reloading the page starts polling again. +A page still `connecting` when polling stops shows "Connecting to the store…" until it is focused +or reloaded. 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` diff --git a/packages/commerce-storefront/docs/server-api.md b/packages/commerce-storefront/docs/server-api.md index dc7e8440..c3ef4fec 100644 --- a/packages/commerce-storefront/docs/server-api.md +++ b/packages/commerce-storefront/docs/server-api.md @@ -135,9 +135,13 @@ state from deployment configuration, a deployed site keeps showing samples until configuration is updated, for example by publishing again. 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 the page starts polling again. 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. +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 the page starts polling again. A +page still `connecting` when polling stops keeps showing that state until it is focused or +reloaded, 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. From 35c1cdecf01a176e80be319952eb948c2cd9e224 Mon Sep 17 00:00:00 2001 From: Scott Bolinger Date: Fri, 23 Oct 2026 10:58:18 -0700 Subject: [PATCH 5/8] Offer a retry when connecting outlasts polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once polling stops, nothing refreshes a page that is still connecting, so it showed "Connecting to the store…" with no way forward. Report it as a connection error with "Retry connection". Co-Authored-By: Claude Opus 5.5 --- .changeset/bounded-config-polling.md | 2 +- packages/commerce-storefront/README.md | 6 ++--- .../commerce-storefront/docs/server-api.md | 4 ++-- .../src/commerce-provider.tsx | 16 ++++++++++++- .../src/storefront.test.tsx | 24 +++++++++++++++++++ 5 files changed, 45 insertions(+), 7 deletions(-) diff --git a/.changeset/bounded-config-polling.md b/.changeset/bounded-config-polling.md index 978232a1..73cb084e 100644 --- a/.changeset/bounded-config-polling.md +++ b/.changeset/bounded-config-polling.md @@ -2,4 +2,4 @@ '@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 starts polling again, and focus and `retryConnection()` still refresh configuration. +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 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. diff --git a/packages/commerce-storefront/README.md b/packages/commerce-storefront/README.md index 15c4b23c..e7662ec0 100644 --- a/packages/commerce-storefront/README.md +++ b/packages/commerce-storefront/README.md @@ -528,9 +528,9 @@ Sample grids and drawers have the attribute `data-commerce-source="sample"`. 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 the page starts polling again. -A page still `connecting` when polling stops shows "Connecting to the store…" until it is focused -or reloaded. 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 +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. diff --git a/packages/commerce-storefront/docs/server-api.md b/packages/commerce-storefront/docs/server-api.md index c3ef4fec..49c71217 100644 --- a/packages/commerce-storefront/docs/server-api.md +++ b/packages/commerce-storefront/docs/server-api.md @@ -137,8 +137,8 @@ configuration is updated, for example by publishing again. 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 the page starts polling again. A -page still `connecting` when polling stops keeps showing that state until it is focused or -reloaded, so finish connecting well within thirty minutes. A failed refresh immediately hides +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 diff --git a/packages/commerce-storefront/src/commerce-provider.tsx b/packages/commerce-storefront/src/commerce-provider.tsx index 9a3e6a0e..6e538403 100644 --- a/packages/commerce-storefront/src/commerce-provider.tsx +++ b/packages/commerce-storefront/src/commerce-provider.tsx @@ -234,6 +234,11 @@ export function CommerceProvider({ }: CommerceProviderProps): ReactElement { const resolvedLocale: string = useMemo(() => resolveLocale(locale), [locale]); const [mountedAt] = useState(Date.now); + const [pollingStopped, setPollingStopped] = useState(false); + useEffect(() => { + const timeout = setTimeout(() => setPollingStopped(true), STOP_POLLING_AT_MS); + return () => clearTimeout(timeout); + }, []); const config = useQuery({ retry: false, queryKey: ['commerce', 'configuration'], @@ -259,6 +264,9 @@ 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'; return ( { void config.refetch(); }} diff --git a/packages/commerce-storefront/src/storefront.test.tsx b/packages/commerce-storefront/src/storefront.test.tsx index ca0e80c1..1d05e93d 100644 --- a/packages/commerce-storefront/src/storefront.test.tsx +++ b/packages/commerce-storefront/src/storefront.test.tsx @@ -784,6 +784,30 @@ describe('bounded polling', () => { } }); + 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(); + 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(); + const read = configReads(api); + fireEvent.click(screen.getByRole('button', { name: 'Retry connection' })); + await vi.waitFor(() => expect(configReads(api)).toBe(read + 1)); + expect(screen.getByRole('button', { name: 'Retry connection' })).toBeVisible(); + 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('polls again after a reload once polling has stopped', async () => { vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval', 'Date'] }); try { From c83257445756ae786eed3512ddb880d3b2f30334 Mon Sep 17 00:00:00 2001 From: Scott Bolinger Date: Mon, 26 Oct 2026 08:34:48 -0700 Subject: [PATCH 6/8] Restart polling when retrying a stalled connection Retry refetched once, but polling stayed stopped, so a page still connecting went straight back to the error. Retrying now starts a new polling period, so the page shows connecting again and polls. Co-Authored-By: Claude Opus 5.5 --- .changeset/bounded-config-polling.md | 2 +- packages/commerce-storefront/README.md | 2 +- packages/commerce-storefront/docs/server-api.md | 2 +- .../commerce-storefront/src/commerce-provider.tsx | 13 ++++++++----- .../commerce-storefront/src/storefront.test.tsx | 10 ++++++++-- 5 files changed, 19 insertions(+), 10 deletions(-) diff --git a/.changeset/bounded-config-polling.md b/.changeset/bounded-config-polling.md index 73cb084e..d3e7ecaa 100644 --- a/.changeset/bounded-config-polling.md +++ b/.changeset/bounded-config-polling.md @@ -2,4 +2,4 @@ '@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 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. +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 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. diff --git a/packages/commerce-storefront/README.md b/packages/commerce-storefront/README.md index e7662ec0..ab64110b 100644 --- a/packages/commerce-storefront/README.md +++ b/packages/commerce-storefront/README.md @@ -527,7 +527,7 @@ Sample grids and drawers have the attribute `data-commerce-source="sample"`. 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 the page starts polling again. +then every minute, and stops thirty minutes after load. Reloading or retrying 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 diff --git a/packages/commerce-storefront/docs/server-api.md b/packages/commerce-storefront/docs/server-api.md index 49c71217..9eb5b62d 100644 --- a/packages/commerce-storefront/docs/server-api.md +++ b/packages/commerce-storefront/docs/server-api.md @@ -136,7 +136,7 @@ configuration is updated, for example by publishing again. 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 the page starts polling again. A +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 diff --git a/packages/commerce-storefront/src/commerce-provider.tsx b/packages/commerce-storefront/src/commerce-provider.tsx index 6e538403..bc0a865f 100644 --- a/packages/commerce-storefront/src/commerce-provider.tsx +++ b/packages/commerce-storefront/src/commerce-provider.tsx @@ -233,12 +233,14 @@ export function CommerceProvider({ onEvent, }: CommerceProviderProps): ReactElement { const resolvedLocale: string = useMemo(() => resolveLocale(locale), [locale]); - const [mountedAt] = useState(Date.now); + // Polling is timed from load, and a retry starts it over. + const [pollingSince, setPollingSince] = useState(Date.now); const [pollingStopped, setPollingStopped] = useState(false); useEffect(() => { - const timeout = setTimeout(() => setPollingStopped(true), STOP_POLLING_AT_MS); + 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'], @@ -247,10 +249,10 @@ export function CommerceProvider({ refetchOnMount: 'always', refetchOnWindowFocus: 'always', // Errors after an unbound response also need recovery; stale data never grants eligibility. - // A site can stay unbound for days, so polling ends; reloading the page starts it again. + // 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() - mountedAt; + const elapsed = Date.now() - pollingSince; if (!waiting || elapsed >= STOP_POLLING_AT_MS) return false; return elapsed < FAST_POLL_FOR_MS ? 5_000 : 60_000; }, @@ -286,6 +288,7 @@ export function CommerceProvider({ : null } retryConnection={() => { + setPollingSince(Date.now()); void config.refetch(); }} theme={theme} diff --git a/packages/commerce-storefront/src/storefront.test.tsx b/packages/commerce-storefront/src/storefront.test.tsx index 1d05e93d..fc38973a 100644 --- a/packages/commerce-storefront/src/storefront.test.tsx +++ b/packages/commerce-storefront/src/storefront.test.tsx @@ -796,10 +796,16 @@ describe('bounded polling', () => { 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(configReads(api)).toBe(read + 1)); - expect(screen.getByRole('button', { name: 'Retry connection' })).toBeVisible(); + 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')); From 7412b2eeb3ae15cf533056125ee78c89e9706f76 Mon Sep 17 00:00:00 2001 From: Scott Bolinger Date: Mon, 26 Oct 2026 08:51:07 -0700 Subject: [PATCH 7/8] Show connecting for at least a second when a retry fails Co-Authored-By: Claude Opus 5.5 --- .changeset/bounded-config-polling.md | 2 +- .../src/commerce-provider.tsx | 25 +++++++++++++------ .../src/storefront.test.tsx | 21 ++++++++++++++++ 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/.changeset/bounded-config-polling.md b/.changeset/bounded-config-polling.md index d3e7ecaa..04c21894 100644 --- a/.changeset/bounded-config-polling.md +++ b/.changeset/bounded-config-polling.md @@ -2,4 +2,4 @@ '@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 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. +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 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. diff --git a/packages/commerce-storefront/src/commerce-provider.tsx b/packages/commerce-storefront/src/commerce-provider.tsx index bc0a865f..0ca2e9e8 100644 --- a/packages/commerce-storefront/src/commerce-provider.tsx +++ b/packages/commerce-storefront/src/commerce-provider.tsx @@ -171,6 +171,8 @@ 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 { @@ -236,6 +238,7 @@ export function CommerceProvider({ // Polling is timed from load, and a retry starts it over. const [pollingSince, setPollingSince] = useState(Date.now); const [pollingStopped, setPollingStopped] = useState(false); + const [retrying, setRetrying] = useState(false); useEffect(() => { setPollingStopped(false); const timeout = setTimeout(() => setPollingStopped(true), pollingSince + STOP_POLLING_AT_MS - Date.now()); @@ -269,6 +272,8 @@ export function CommerceProvider({ // 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 ( { - setPollingSince(Date.now()); - void config.refetch(); + const started = Date.now(); + setPollingSince(started); + 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} diff --git a/packages/commerce-storefront/src/storefront.test.tsx b/packages/commerce-storefront/src/storefront.test.tsx index fc38973a..1df616b8 100644 --- a/packages/commerce-storefront/src/storefront.test.tsx +++ b/packages/commerce-storefront/src/storefront.test.tsx @@ -814,6 +814,27 @@ describe('bounded polling', () => { } }); + 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(); + 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('polls again after a reload once polling has stopped', async () => { vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval', 'Date'] }); try { From c46a3d80ebbdfdb0da4031b0f9836988fc6bb92c Mon Sep 17 00:00:00 2001 From: Scott Bolinger Date: Mon, 26 Oct 2026 09:04:38 -0700 Subject: [PATCH 8/8] Keep the connection state when retrying without an error, and restart polling when the reported state changes Co-Authored-By: Claude Opus 5.5 --- .changeset/bounded-config-polling.md | 2 +- packages/commerce-storefront/README.md | 5 ++- .../src/commerce-provider.tsx | 14 +++++- .../src/storefront.test.tsx | 44 +++++++++++++++++++ 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/.changeset/bounded-config-polling.md b/.changeset/bounded-config-polling.md index 04c21894..550330e1 100644 --- a/.changeset/bounded-config-polling.md +++ b/.changeset/bounded-config-polling.md @@ -2,4 +2,4 @@ '@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 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. +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. diff --git a/packages/commerce-storefront/README.md b/packages/commerce-storefront/README.md index ab64110b..9da71282 100644 --- a/packages/commerce-storefront/README.md +++ b/packages/commerce-storefront/README.md @@ -527,8 +527,9 @@ Sample grids and drawers have the attribute `data-commerce-source="sample"`. 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 or retrying starts it again. -A page still `connecting` when polling stops shows an error with "Retry connection". The first +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 diff --git a/packages/commerce-storefront/src/commerce-provider.tsx b/packages/commerce-storefront/src/commerce-provider.tsx index 0ca2e9e8..4c915909 100644 --- a/packages/commerce-storefront/src/commerce-provider.tsx +++ b/packages/commerce-storefront/src/commerce-provider.tsx @@ -235,7 +235,7 @@ export function CommerceProvider({ onEvent, }: CommerceProviderProps): ReactElement { const resolvedLocale: string = useMemo(() => resolveLocale(locale), [locale]); - // Polling is timed from load, and a retry starts it over. + // 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(false); const [retrying, setRetrying] = useState(false); @@ -261,6 +261,13 @@ export function CommerceProvider({ }, 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 = @@ -295,6 +302,11 @@ export function CommerceProvider({ retryConnection={() => { 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) diff --git a/packages/commerce-storefront/src/storefront.test.tsx b/packages/commerce-storefront/src/storefront.test.tsx index 1df616b8..495507c2 100644 --- a/packages/commerce-storefront/src/storefront.test.tsx +++ b/packages/commerce-storefront/src/storefront.test.tsx @@ -835,6 +835,50 @@ describe('bounded polling', () => { } }); + 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(); + 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 {