diff --git a/packages/commerce-sdk-react/CHANGELOG.md b/packages/commerce-sdk-react/CHANGELOG.md index b9c8787d09..296ef3e3e0 100644 --- a/packages/commerce-sdk-react/CHANGELOG.md +++ b/packages/commerce-sdk-react/CHANGELOG.md @@ -3,6 +3,7 @@ - Fix cannot read properties of undefined (reading 'unshift') [#1689](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/1689) - Add Shopper SEO hook [#1688](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/1688) - Update useLocalStorage implementation to be more responsive [#1703](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/1703) +- Storefront Preview: avoid stale cached Commerce API responses, whenever the Shopper Context is set [#1701](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/1701) ## v1.3.0 (Jan 19, 2024) diff --git a/packages/commerce-sdk-react/src/components/StorefrontPreview/storefront-preview.test.tsx b/packages/commerce-sdk-react/src/components/StorefrontPreview/storefront-preview.test.tsx index 1214859aeb..57341754f4 100644 --- a/packages/commerce-sdk-react/src/components/StorefrontPreview/storefront-preview.test.tsx +++ b/packages/commerce-sdk-react/src/components/StorefrontPreview/storefront-preview.test.tsx @@ -4,15 +4,17 @@ * SPDX-License-Identifier: BSD-3-Clause * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ -import React from 'react' +import React, {useEffect} from 'react' import {render, waitFor} from '@testing-library/react' import StorefrontPreview from './storefront-preview' import {detectStorefrontPreview} from './utils' import {Helmet} from 'react-helmet' +import {mockQueryEndpoint, renderWithProviders} from '../../test-utils' +import {useCommerceApi} from '../../hooks' declare global { interface Window { - STOREFRONT_PREVIEW: Record + STOREFRONT_PREVIEW?: Record } } @@ -23,18 +25,14 @@ jest.mock('./utils', () => { detectStorefrontPreview: jest.fn() } }) +jest.mock('../../auth/index.ts') describe('Storefront Preview Component', function () { - const oldWindow = window - beforeEach(() => { - // eslint-disable-next-line - window = {...oldWindow} + delete window.STOREFRONT_PREVIEW }) - afterEach(() => { - // eslint-disable-next-line - window = oldWindow + jest.restoreAllMocks() }) test('Renders children when enabled', () => { @@ -91,23 +89,48 @@ describe('Storefront Preview Component', function () { }) }) - test('getToken is defined in window.STOREFRONT_PREVIEW when it is defined', () => { - window.STOREFRONT_PREVIEW = {} + test('window.STOREFRONT_PREVIEW is defined properly', () => { ;(detectStorefrontPreview as jest.Mock).mockReturnValue(true) - render( 'my-token'} />) - expect(window.STOREFRONT_PREVIEW.getToken).toBeDefined() + render( + 'my-token'} + onContextChange={() => {}} + /> + ) + expect(window.STOREFRONT_PREVIEW?.getToken).toBeDefined() + expect(window.STOREFRONT_PREVIEW?.onContextChange).toBeDefined() + expect(window.STOREFRONT_PREVIEW?.experimentalUnsafeNavigate).toBeDefined() }) - test('onContextChange is defined in window.STOREFRONT_PREVIEW when it is defined', () => { - window.STOREFRONT_PREVIEW = {} + test('cache breaker is added to the parameters of SCAPI requests, only if in storefront preview', () => { ;(detectStorefrontPreview as jest.Mock).mockReturnValue(true) + mockQueryEndpoint('baskets/123', {}) - render( 'my-token'} onContextChange={() => undefined} />) - expect(window.STOREFRONT_PREVIEW.onContextChange).toBeDefined() - }) + jest.spyOn(Date, 'now').mockImplementation(() => 1000) - test('experimental unsafe props are defined', () => { - expect(window.STOREFRONT_PREVIEW.experimentalUnsafeNavigate).toBeDefined() + let getBasketSpy + const parameters = {basketId: '123'} + const MockedComponent = ({enableStorefrontPreview}: {enableStorefrontPreview: boolean}) => { + const apiClients = useCommerceApi() + getBasketSpy = jest.spyOn(apiClients.shopperBaskets, 'getBasket') + useEffect(() => { + void apiClients.shopperBaskets.getBasket({parameters}) + }, []) + return ( + 'my-token'} /> + ) + } + + renderWithProviders() + expect(getBasketSpy).toHaveBeenCalledWith({ + parameters: {...parameters, c_cache_breaker: 1000} + }) + + renderWithProviders() + expect(getBasketSpy).toHaveBeenCalledWith({ + parameters + }) }) }) diff --git a/packages/commerce-sdk-react/src/components/StorefrontPreview/storefront-preview.tsx b/packages/commerce-sdk-react/src/components/StorefrontPreview/storefront-preview.tsx index 9227ea28c1..c13deda603 100644 --- a/packages/commerce-sdk-react/src/components/StorefrontPreview/storefront-preview.tsx +++ b/packages/commerce-sdk-react/src/components/StorefrontPreview/storefront-preview.tsx @@ -8,9 +8,10 @@ import React, {useEffect} from 'react' import PropTypes from 'prop-types' import {Helmet} from 'react-helmet' -import {CustomPropTypes, detectStorefrontPreview, getClientScript} from './utils' +import {CustomPropTypes, detectStorefrontPreview, getClientScript, proxyRequests} from './utils' import {useHistory} from 'react-router-dom' import type {LocationDescriptor} from 'history' +import {useCommerceApi} from '../../hooks' type GetToken = () => string | undefined | Promise type ContextChangeHandler = () => void | Promise @@ -33,7 +34,8 @@ export const StorefrontPreview = ({ >) => { const history = useHistory() const isHostTrusted = detectStorefrontPreview() - + const apiClients = useCommerceApi() + useEffect(() => { if (enabled && isHostTrusted) { window.STOREFRONT_PREVIEW = { @@ -51,6 +53,26 @@ export const StorefrontPreview = ({ } }, [enabled, getToken, onContextChange]) + useEffect(() => { + if (enabled && isHostTrusted) { + // In Storefront Preview mode, add cache breaker for all SCAPI's requests. + // Otherwise, it's possible to get stale responses after the Shopper Context is set. + // (i.e. in this case, we optimize for accurate data, rather than performance/caching) + proxyRequests(apiClients, { + apply(target, thisArg, argumentsList) { + argumentsList[0] = { + ...argumentsList[0], + parameters: { + ...argumentsList[0]?.parameters, + c_cache_breaker: Date.now() + } + } + return target.call(thisArg, ...argumentsList) + } + }) + } + }, [apiClients, enabled]) + return ( <> {enabled && isHostTrusted && ( diff --git a/packages/commerce-sdk-react/src/components/StorefrontPreview/utils.test.ts b/packages/commerce-sdk-react/src/components/StorefrontPreview/utils.test.ts index 7fe29d297c..ca7badaab8 100644 --- a/packages/commerce-sdk-react/src/components/StorefrontPreview/utils.test.ts +++ b/packages/commerce-sdk-react/src/components/StorefrontPreview/utils.test.ts @@ -5,8 +5,10 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ import type {DOMWindow, JSDOM} from 'jsdom' +import {useCommerceApi} from '../../hooks' +import {renderHookWithProviders} from '../../test-utils' import {getParentOrigin} from '../../utils' -import {getClientScript, detectStorefrontPreview} from './utils' +import {getClientScript, detectStorefrontPreview, proxyRequests} from './utils' const LOCALHOST_ORIGIN = 'http://localhost:4000' const RUNTIME_ADMIN_ORIGIN = 'https://runtime.commercecloud.com' @@ -23,6 +25,8 @@ const mockTop = new Proxy({} as DOMWindow, { } }) +jest.mock('../../auth/index.ts') + describe('Storefront Preview utils', () => { let originalLocation: string let referrerSpy: jest.SpyInstance @@ -92,4 +96,22 @@ describe('Storefront Preview utils', () => { expect(detectStorefrontPreview()).toBe(false) }) }) + + describe('proxyRequests', () => { + test('proxy handlers applied to all client methods', async () => { + const {result} = renderHookWithProviders(() => useCommerceApi()) + const clients = result.current + const shopperBaskets = clients.shopperBaskets + const handlers = {apply: jest.fn()} + + proxyRequests(clients, handlers) + + await shopperBaskets.getBasket() + await shopperBaskets.getTaxesFromBasket() + await shopperBaskets.getPriceBooksForBasket() + await shopperBaskets.getShippingMethodsForShipment() + + expect(handlers.apply).toHaveBeenCalledTimes(4) + }) + }) }) diff --git a/packages/commerce-sdk-react/src/components/StorefrontPreview/utils.ts b/packages/commerce-sdk-react/src/components/StorefrontPreview/utils.ts index e2f789e02a..dab7fecd30 100644 --- a/packages/commerce-sdk-react/src/components/StorefrontPreview/utils.ts +++ b/packages/commerce-sdk-react/src/components/StorefrontPreview/utils.ts @@ -5,6 +5,7 @@ * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause */ +import {ApiClients} from '../../hooks/types' import {DEVELOPMENT_ORIGIN, getParentOrigin, isOriginTrusted} from '../../utils' /** Detects whether the storefront is running in an iframe as part of Storefront Preview. @@ -30,10 +31,10 @@ export const CustomPropTypes = { /** * This custom PropType ensures that the prop is only required when the known prop * "enabled" is set to "true". - * - * @param props - * @param propName - * @param componentName + * + * @param props + * @param propName + * @param componentName * @returns */ requiredFunctionWhenEnabled: (props: any, propName: any, componentName: any) => { @@ -48,4 +49,18 @@ export const CustomPropTypes = { ) } } -} \ No newline at end of file +} + +/** + * Via the built-in Proxy object, modify the behaviour of each request for the given SCAPI clients + * @private + */ +export const proxyRequests = (clients: ApiClients, handlers: ProxyHandler) => { + Object.values(clients).forEach((client: Record) => { + const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(client)) + + methods.forEach((method) => { + client[method] = new Proxy(client[method], handlers) + }) + }) +} diff --git a/packages/commerce-sdk-react/src/hooks/useCommerceApi.test.tsx b/packages/commerce-sdk-react/src/hooks/useCommerceApi.test.tsx deleted file mode 100644 index eb23a5c992..0000000000 --- a/packages/commerce-sdk-react/src/hooks/useCommerceApi.test.tsx +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright (c) 2023, Salesforce, Inc. - * All rights reserved. - * SPDX-License-Identifier: BSD-3-Clause - * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause - */ -import 'react' -import useCommerceApi from './useCommerceApi' -import {renderHookWithProviders} from '../test-utils' -import {ApiClients} from './types' - -jest.mock('../auth/index.ts') - -describe('useCommerceApi', () => { - test('returns a set of api clients', () => { - const clients: (keyof ApiClients)[] = [ - 'shopperBaskets', - 'shopperContexts', - 'shopperCustomers', - 'shopperGiftCertificates', - 'shopperLogin', - 'shopperOrders', - 'shopperProducts', - 'shopperPromotions', - 'shopperSearch' - ] - const {result} = renderHookWithProviders(() => useCommerceApi()) - clients.forEach((name) => { - expect(result.current[name]).toBeDefined() - }) - }) -})