Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,88 @@ 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('experimentalUnsafeNavigate does not strip when path has basePath only as substring (e.g. /shop vs /shopping/cart)', () => {
;(detectStorefrontPreview as jest.Mock).mockReturnValue(true)

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

window.STOREFRONT_PREVIEW?.experimentalUnsafeNavigate?.('/shopping/cart', 'push')
expect(mockPush).toHaveBeenCalledWith('/shopping/cart')
})

test('experimentalUnsafeNavigate strips to / when path exactly equals basePath', () => {
;(detectStorefrontPreview as jest.Mock).mockReturnValue(true)

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

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

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

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

window.STOREFRONT_PREVIEW?.experimentalUnsafeNavigate?.(
{pathname: '/mybase/product/123', search: '?q=1'},
'push'
)
expect(mockPush).toHaveBeenCalledWith({pathname: '/product/123', search: '?q=1'})
})

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,57 @@ type GetToken = () => string | undefined | Promise<string | undefined>
type ContextChangeHandler = () => void | Promise<void>
type OptionalWhenDisabled<T> = ({enabled?: true} & T) | ({enabled: false} & Partial<T>)

/**
* Remove the base path from a path string.
* Only strips when path equals basePath or path starts with basePath + '/'.
*/
function removeBasePathFromPath(path: string, basePath: string): string {
const matches =
path.startsWith(basePath + '/') || path === basePath
return matches ? path.slice(basePath.length) || '/' : path
}

/**
* 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 removeBasePathFromPath(pathOrLocation, basePath) as LocationDescriptor<T>
}
const pathname = pathOrLocation.pathname ?? '/'
return {
...pathOrLocation,
pathname: removeBasePathFromPath(pathname, basePath)
}
}

/**
*
* @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 +86,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 +138,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
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ export const detectStorefrontPreview = () => {
export const getClientScript = () => {
const parentOrigin = getParentOrigin() ?? 'https://runtime.commercecloud.com'
return parentOrigin === DEVELOPMENT_ORIGIN
// TODO: This will need to be updated to support base paths with storefront preview
? `${parentOrigin}${LOCAL_BUNDLE_PATH}/static/storefront-preview.js`
: `${parentOrigin}/cc/b2c/preview/preview.client.js`
}
Expand Down
1 change: 1 addition & 0 deletions packages/pwa-kit-runtime/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
## v3.16.0-dev (Dec 17, 2025)
- 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)

## v3.15.0 (Dec 17, 2025)
- Fix multiple set-cookie headers [#3508](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3508)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -602,8 +602,9 @@ export const RemoteServerFactory = {
return next()
}

// For other routes, only proceed if path actually starts with base path
if (!req.path.startsWith(basePath)) {
// For other routes, only proceed if path equals basePath or path starts with basePath + '/'
const pathMatchesBasePath = req.path === basePath || req.path.startsWith(basePath + '/')
if (!pathMatchesBasePath) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The removeBasePathFromPath function just below this already accounts for the substring scenario so this change just tightening the check before we get to that point

return next()
}

Expand Down
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
18 changes: 15 additions & 3 deletions packages/template-retail-react-app/app/utils/site-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,20 @@ export const getSiteByReference = (siteRef) => {
)
}

/**
* Remove the base path from a path string only when path equals basePath or path starts with basePath + '/'.
* @param {string} path - the path to strip
* @param {string} basePath - the base path to remove
* @returns {string} the path with base path removed, or the original path
*/
export const removeBasePathFromPath = (path, basePath) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

The logic looks good, the only nit is that I see we are duplicating the logic across different packages.

An idea would be to implement it in the one that is common commerce-sdk-react and import it in the template.

E.g.

  1. Move the logic to a utils files and export the one in commerce-sdk-react

    function removeBasePathFromPath(path: string, basePath: string): string {

  2. Remove this local definition

    export const removeBasePathFromPath = (path, basePath) => {

  3. Import from commerce-skd-react:

import {removeBasePathFromPath} from '@salesforce/commerce-sdk-react/components'

But as I say is a nit and up to you that you have the whole picture on where the centralized basePath logic lives

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 @adamraya . I get that this is a duplication but I'd like to keep the separation for now since it doesn't quite make sense to import functionality relating to base paths from commerce-sdk-react.

site-utils is a more reasonable location for the code as there are other functions in that file that also operate on the URL

if (!basePath) return path
if (path.startsWith(basePath + '/') || path === basePath) {
return path.substring(basePath.length) || '/'
}
return path
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I added a new util function here to handle base path removal while accounting for the substring scenario @adamraya .

The update in site-utils and below in url.js account for fixing the substring scenario when startsWith is used

}

/**
* This function return the identifiers (site and locale) from the given url
* The site will always go before locale if both of them are presented in the pathname
Expand All @@ -107,9 +121,7 @@ export const getParamsFromPath = (path) => {
// Remove the base path from the pathname if present since
// it shifts the location of the site and locale in the pathname
const basePath = getRouterBasePath()
if (basePath && pathname.startsWith(basePath)) {
pathname = pathname.substring(basePath.length)
}
pathname = removeBasePathFromPath(pathname, basePath)

const config = getConfig()
const {pathMatcher, searchMatcherForSite, searchMatcherForLocale} = getConfigMatcher(config)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ import {getRouterBasePath} from '@salesforce/pwa-kit-react-sdk/ssr/universal/uti
import mockConfig from '@salesforce/retail-react-app/config/mocks/default'
import {
getParamsFromPath,
resolveLocaleFromUrl
resolveLocaleFromUrl,
removeBasePathFromPath
} from '@salesforce/retail-react-app/app/utils/site-utils'
jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => {
const origin = jest.requireActual('@salesforce/pwa-kit-react-sdk/ssr/universal/utils')
Expand Down Expand Up @@ -341,6 +342,43 @@ describe('getParamsFromPath', function () {
const result = getParamsFromPath(path)
expect(result).toEqual({siteRef: 'us', localeRef: 'en-US'})
})

test('should not strip when path has basePath only as substring (e.g. /shop vs /shopping/cart)', () => {
const basePath = '/shop'
getRouterBasePath.mockReturnValue(basePath)
getConfig.mockImplementation(() => ({
...mockConfig,
app: {
...mockConfig.app,
url: {
...mockConfig.app.url,
showBasePath: true
}
}
}))

const result = getParamsFromPath('/shopping/cart')
expect(result).toBeDefined()
})
})
})

describe('removeBasePathFromPath', () => {
test('removes when path starts with basePath + "/"', () => {
expect(removeBasePathFromPath('/shop/cart', '/shop')).toBe('/cart')
expect(removeBasePathFromPath('/test-base/uk/en-GB/foo', '/test-base')).toBe(
'/uk/en-GB/foo'
)
})
test('removes to "/" when path exactly equals basePath', () => {
expect(removeBasePathFromPath('/shop', '/shop')).toBe('/')
})
test('does not remove when basePath is only a substring (e.g. /shop vs /shopping/cart)', () => {
expect(removeBasePathFromPath('/shopping/cart', '/shop')).toBe('/shopping/cart')
expect(removeBasePathFromPath('/shopping', '/shop')).toBe('/shopping')
})
test('returns path unchanged when basePath is empty', () => {
expect(removeBasePathFromPath('/any/path', '')).toBe('/any/path')
})
})

Expand Down
11 changes: 4 additions & 7 deletions packages/template-retail-react-app/app/utils/url.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import {
getLocaleByReference,
getParamsFromPath,
getDefaultSite,
getSiteByReference
getSiteByReference,
removeBasePathFromPath
} from '@salesforce/retail-react-app/app/utils/site-utils'
import {HOME_HREF, urlPartPositions} from '@salesforce/retail-react-app/app/constants'
import {getRouterBasePath} from '@salesforce/pwa-kit-react-sdk/ssr/universal/utils'
Expand Down Expand Up @@ -121,9 +122,7 @@ export const getPathWithLocale = (shortCode, buildUrl, opts = {}) => {

// sanitize the base path from current url if existing
const basePath = getRouterBasePath()
if (basePath && pathname.startsWith(basePath)) {
pathname = pathname.substring(basePath.length)
}
pathname = removeBasePathFromPath(pathname, basePath)

// sanitize the site from current url if existing
if (siteRef) {
Expand Down Expand Up @@ -269,9 +268,7 @@ export const removeSiteLocaleFromPath = (pathName = '') => {
let {siteRef, localeRef} = getParamsFromPath(pathName)

const basePath = getRouterBasePath()
if (basePath && pathName.startsWith(basePath)) {
pathName = pathName.substring(basePath.length)
}
pathName = removeBasePathFromPath(pathName, basePath)

// remove the site alias from the current pathName
if (siteRef) {
Expand Down
Loading
Loading