Skip to content
Merged
1 change: 1 addition & 0 deletions packages/commerce-sdk-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
- Upgrade to commerce-sdk-isomorphic v5.0.0 and introduce Payment Instrument SCAPI integration [#3552](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3552)
- Update storefront preview to support base paths [#3666](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3666)


## v4.4.0-dev (Dec 17, 2025)
- [Bugfix]Ensure code_verifier can be optional in resetPassword call [#3567](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3567)
- [Improvement] Strengthening typescript types on custom endpoint options and fetchOptions types [#3589](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3589)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,16 @@ import {useCommerceApi, useConfig} from '../../hooks'

declare global {
interface Window {
STOREFRONT_PREVIEW?: Record<string, unknown>
STOREFRONT_PREVIEW?: {
getToken?: () => string | undefined | Promise<string | undefined>
onContextChange?: () => void | Promise<void>
siteId?: string
experimentalUnsafeNavigate?: (
path: string | {pathname: string; search?: string; hash?: string; state?: unknown},
action?: 'push' | 'replace',
...args: unknown[]
) => void
}
}
}

Expand All @@ -28,9 +37,24 @@ jest.mock('./utils', () => {
jest.mock('../../auth/index.ts')
jest.mock('../../hooks/useConfig', () => jest.fn())

const mockPush = jest.fn()
const mockReplace = jest.fn()
jest.mock('react-router-dom', () => {
const actual = jest.requireActual('react-router-dom')
return {
...actual,
useHistory: () => ({
push: mockPush,
replace: mockReplace
})
}
})

describe('Storefront Preview Component', function () {
beforeEach(() => {
delete window.STOREFRONT_PREVIEW
mockPush.mockClear()
mockReplace.mockClear()
;(useConfig as jest.Mock).mockReturnValue({siteId: 'site-id'})
})
afterEach(() => {
Expand Down Expand Up @@ -107,6 +131,40 @@ describe('Storefront Preview Component', function () {
expect(window.STOREFRONT_PREVIEW?.experimentalUnsafeNavigate).toBeDefined()
})

test('experimentalUnsafeNavigate removes base path from path when getBasePath is provided', () => {
;(detectStorefrontPreview as jest.Mock).mockReturnValue(true)

render(
<StorefrontPreview
enabled={true}
getToken={() => 'my-token'}
getBasePath={() => '/mybase'}
/>
)

window.STOREFRONT_PREVIEW?.experimentalUnsafeNavigate?.('/mybase/product/123', 'push')
expect(mockPush).toHaveBeenCalledWith('/product/123')

mockPush.mockClear()
window.STOREFRONT_PREVIEW?.experimentalUnsafeNavigate?.('/mybase/account', 'replace')
expect(mockReplace).toHaveBeenCalledWith('/account')
})

test('experimentalUnsafeNavigate does not remove when path does not start with base path', () => {
;(detectStorefrontPreview as jest.Mock).mockReturnValue(true)

render(
<StorefrontPreview
enabled={true}
getToken={() => 'my-token'}
getBasePath={() => '/mybase'}
/>
)

window.STOREFRONT_PREVIEW?.experimentalUnsafeNavigate?.('/other/product/123', 'push')
expect(mockPush).toHaveBeenCalledWith('/other/product/123')
})

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', {})
Expand Down
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, I noticed this TODO related to basePath:

// TODO: This will need to be updated to support base paths with storefront preview

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for catching that!

I've removed that comment since we don't actually need to make any changes on that section of code. No updates are required since the client script is fetched from runtime admin and not the PWA bundle.

Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,49 @@ type GetToken = () => string | undefined | Promise<string | undefined>
type ContextChangeHandler = () => void | Promise<void>
type OptionalWhenDisabled<T> = ({enabled?: true} & T) | ({enabled: false} & Partial<T>)

/**
* Strip the base path from a path
*
* React Router history re-adds the base path to the path, so we
* remove it here to avoid base path duplication.
*/
function removeBasePathFromLocation<T>(
pathOrLocation: LocationDescriptor<T>,
basePath: string
): LocationDescriptor<T> {
if (!basePath) return pathOrLocation
if (typeof pathOrLocation === 'string') {
return pathOrLocation.startsWith(basePath)
? pathOrLocation.slice(basePath.length) || '/'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently startsWith will match any prefix matching so if the basePath is '/shop' and the url is '/shopping/cart' the conditional will be true.

Should we do something like?

const matches = pathOrLocation.startsWith(basePath + '/') || pathOrLocation === basePath
return matches ? pathOrLocation.slice(basePath.length) || '/' : pathOrLocation

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch!

I did a blanket check and fixed other places where use of startsWith could run into the same issue.

: pathOrLocation
}
const pathname = pathOrLocation.pathname ?? '/'
const strippedPathname = pathname.startsWith(basePath)
? pathname.slice(basePath.length) || '/'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

: pathname
return {...pathOrLocation, pathname: strippedPathname}
}

/**
*
* @param enabled - flag to turn on/off Storefront Preview feature. By default, it is set to true.
* This flag only applies if storefront is running in a Runtime Admin iframe.
* @param getToken - A method that returns the access token for the current user
* @param getBasePath - A method that returns the router base path of the app.
*/
export const StorefrontPreview = ({
children,
enabled = true,
getToken,
onContextChange
onContextChange,
getBasePath
}: React.PropsWithChildren<
// Props are only required when Storefront Preview is enabled
OptionalWhenDisabled<{getToken: GetToken; onContextChange?: ContextChangeHandler}>
OptionalWhenDisabled<{
getToken: GetToken
onContextChange?: ContextChangeHandler
getBasePath?: () => string
}>
>) => {
const history = useHistory()
const isHostTrusted = detectStorefrontPreview()
Expand All @@ -49,11 +78,13 @@ export const StorefrontPreview = ({
action: 'push' | 'replace' = 'push',
...args: unknown[]
) => {
history[action](path, ...args)
const basePath = getBasePath?.() ?? ''
const pathWithoutBase = removeBasePathFromLocation(path, basePath)
history[action](pathWithoutBase, ...args)
}
}
}
}, [enabled, getToken, onContextChange, siteId])
}, [enabled, getToken, onContextChange, siteId, getBasePath])

useEffect(() => {
if (enabled && isHostTrusted) {
Expand Down Expand Up @@ -99,7 +130,8 @@ StorefrontPreview.propTypes = {
// to get to a place where both these props are simply optional and we will provide default implementations.
// This would make the API simpler to use.
getToken: CustomPropTypes.requiredFunctionWhenEnabled,
onContextChange: PropTypes.func
onContextChange: PropTypes.func,
getBasePath: PropTypes.func
}

export default StorefrontPreview
1 change: 1 addition & 0 deletions packages/template-retail-react-app/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
- [Feature] One Click Checkout [#3552](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3552)
- Move envBasePath into ssrParameters [#3590](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3590)
- Support adding base paths to shopper facing URLs [#3615](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3615)
- Update storefront preview to support base paths [#3666](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3666)
- [Feature] Add `fuzzyPathMatching` to reduce computational overhead of route generation at time of application load [#3530](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3530)
- [Bugfix] Fix Passwordless Login landingPath, Reset Password landingPath, and Social Login redirectUri value in config not being used [#3560](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3560)
- [Feature] PWA Integration with OMS
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ const App = (props) => {

return (
<Box className="sf-app" {...styles.container}>
<StorefrontPreview getToken={getTokenWhenReady}>
<StorefrontPreview getToken={getTokenWhenReady} getBasePath={getRouterBasePath}>
<IntlProvider
onError={(err) => {
if (!messages) {
Expand Down
Loading