diff --git a/.changeset/bounded-config-polling.md b/.changeset/bounded-config-polling.md new file mode 100644 index 00000000..550330e1 --- /dev/null +++ b/.changeset/bounded-config-polling.md @@ -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. diff --git a/packages/commerce-storefront/README.md b/packages/commerce-storefront/README.md index c9d808b6..9da71282 100644 --- a/packages/commerce-storefront/README.md +++ b/packages/commerce-storefront/README.md @@ -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` diff --git a/packages/commerce-storefront/docs/server-api.md b/packages/commerce-storefront/docs/server-api.md index de0f8b00..9eb5b62d 100644 --- a/packages/commerce-storefront/docs/server-api.md +++ b/packages/commerce-storefront/docs/server-api.md @@ -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. diff --git a/packages/commerce-storefront/src/commerce-provider.tsx b/packages/commerce-storefront/src/commerce-provider.tsx index 3ca83c18..4c915909 100644 --- a/packages/commerce-storefront/src/commerce-provider.tsx +++ b/packages/commerce-storefront/src/commerce-provider.tsx @@ -168,6 +168,11 @@ type PublicConfiguration = | ({ state: 'ready' } & Pick); 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 { @@ -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(false); + const [retrying, setRetrying] = useState(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'], @@ -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 = @@ -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 ( { - 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} diff --git a/packages/commerce-storefront/src/storefront.test.tsx b/packages/commerce-storefront/src/storefront.test.tsx index 2e53b0de..495507c2 100644 --- a/packages/commerce-storefront/src/storefront.test.tsx +++ b/packages/commerce-storefront/src/storefront.test.tsx @@ -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) => + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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', () => { it('preserves samples across routed pages but resets when the storefront is remounted', async () => { mockApi(() => response({ state: 'unbound' }));