Skip to content

Commit

Permalink
[Storefront Preview] Avoid stale SCAPI responses (@W-15214446@) (#1701)
Browse files Browse the repository at this point in the history
* Attempt to inject cache breaker globally

* Break cache only when in storefront preview mode

* Refactor so the changes live in commerce-sdk-react only

* Further refactoring

* Identify Storefront Preview mode later (to be more accurate)

* Remove ts-ignore

* Move and isolate changes to the StorefrontPreview component

* Detect when the API clients are updated

* Add useEffect dependency

* Refactor setting the cache breaker for readability

* Proxy all SCAPI requests

* Add test

* Resolve typescript errors

* Some test refactoring

* Fix leaking of window.STOREFRONT_PREVIEW

* Add another test

* Add another assertion

* Update CHANGELOG.md

* Some test refactoring

* A bit more refactoring

* Remove unnecessary test

* Clean up import

* Use `Date.now()` to be clearer

* Use `Date.now()` to be cleare

* Fix test mock
  • Loading branch information
vmarta authored Mar 27, 2024
1 parent 9305143 commit 8ce2571
Show file tree
Hide file tree
Showing 6 changed files with 111 additions and 60 deletions.
1 change: 1 addition & 0 deletions packages/commerce-sdk-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
STOREFRONT_PREVIEW?: Record<string, unknown>
}
}

Expand All @@ -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', () => {
Expand Down Expand Up @@ -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(<StorefrontPreview getToken={() => 'my-token'} />)
expect(window.STOREFRONT_PREVIEW.getToken).toBeDefined()
render(
<StorefrontPreview
enabled={true}
getToken={() => '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(<StorefrontPreview enabled={true} getToken={() => '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 (
<StorefrontPreview enabled={enableStorefrontPreview} getToken={() => 'my-token'} />
)
}

renderWithProviders(<MockedComponent enableStorefrontPreview={true} />)
expect(getBasketSpy).toHaveBeenCalledWith({
parameters: {...parameters, c_cache_breaker: 1000}
})

renderWithProviders(<MockedComponent enableStorefrontPreview={false} />)
expect(getBasketSpy).toHaveBeenCalledWith({
parameters
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined>
type ContextChangeHandler = () => void | Promise<void>
Expand All @@ -33,7 +34,8 @@ export const StorefrontPreview = ({
>) => {
const history = useHistory()
const isHostTrusted = detectStorefrontPreview()

const apiClients = useCommerceApi()

useEffect(() => {
if (enabled && isHostTrusted) {
window.STOREFRONT_PREVIEW = {
Expand All @@ -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 && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<string>
Expand Down Expand Up @@ -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)
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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) => {
Expand All @@ -48,4 +49,18 @@ export const CustomPropTypes = {
)
}
}
}
}

/**
* 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<any>) => {
Object.values(clients).forEach((client: Record<string, any>) => {
const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(client))

methods.forEach((method) => {
client[method] = new Proxy(client[method], handlers)
})
})
}
32 changes: 0 additions & 32 deletions packages/commerce-sdk-react/src/hooks/useCommerceApi.test.tsx

This file was deleted.

0 comments on commit 8ce2571

Please sign in to comment.