Skip to content

Commit 21cbe2b

Browse files
authored
[Storefront Preview] Address base path duplication for manually entered urls (#3666)
* Address base path duplication * Update CHANGELOG.md * Update CHANGELOG.md * Update CHANGELOG.md * Apply suggestions * Fix unwanted basePath removal if base path is a substring * Lint * Add console warn
1 parent bb8586b commit 21cbe2b

File tree

12 files changed

+236
-21
lines changed

12 files changed

+236
-21
lines changed

packages/commerce-sdk-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
- Upgrade to commerce-sdk-isomorphic v5.0.0 and introduce Payment Instrument SCAPI integration [#3552](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3552)
33
- Update storefront preview to support base paths [#3666](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3666)
44

5+
56
## v4.4.0-dev (Dec 17, 2025)
67
- [Bugfix]Ensure code_verifier can be optional in resetPassword call [#3567](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3567)
78
- [Improvement] Strengthening typescript types on custom endpoint options and fetchOptions types [#3589](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3589)

packages/commerce-sdk-react/src/components/StorefrontPreview/storefront-preview.test.tsx

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,16 @@ import {useCommerceApi, useConfig} from '../../hooks'
1414

1515
declare global {
1616
interface Window {
17-
STOREFRONT_PREVIEW?: Record<string, unknown>
17+
STOREFRONT_PREVIEW?: {
18+
getToken?: () => string | undefined | Promise<string | undefined>
19+
onContextChange?: () => void | Promise<void>
20+
siteId?: string
21+
experimentalUnsafeNavigate?: (
22+
path: string | {pathname: string; search?: string; hash?: string; state?: unknown},
23+
action?: 'push' | 'replace',
24+
...args: unknown[]
25+
) => void
26+
}
1827
}
1928
}
2029

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

40+
const mockPush = jest.fn()
41+
const mockReplace = jest.fn()
42+
jest.mock('react-router-dom', () => {
43+
const actual = jest.requireActual('react-router-dom')
44+
return {
45+
...actual,
46+
useHistory: () => ({
47+
push: mockPush,
48+
replace: mockReplace
49+
})
50+
}
51+
})
52+
3153
describe('Storefront Preview Component', function () {
3254
beforeEach(() => {
3355
delete window.STOREFRONT_PREVIEW
56+
mockPush.mockClear()
57+
mockReplace.mockClear()
3458
;(useConfig as jest.Mock).mockReturnValue({siteId: 'site-id'})
3559
})
3660
afterEach(() => {
@@ -107,6 +131,88 @@ describe('Storefront Preview Component', function () {
107131
expect(window.STOREFRONT_PREVIEW?.experimentalUnsafeNavigate).toBeDefined()
108132
})
109133

134+
test('experimentalUnsafeNavigate removes base path from path when getBasePath is provided', () => {
135+
;(detectStorefrontPreview as jest.Mock).mockReturnValue(true)
136+
137+
render(
138+
<StorefrontPreview
139+
enabled={true}
140+
getToken={() => 'my-token'}
141+
getBasePath={() => '/mybase'}
142+
/>
143+
)
144+
145+
window.STOREFRONT_PREVIEW?.experimentalUnsafeNavigate?.('/mybase/product/123', 'push')
146+
expect(mockPush).toHaveBeenCalledWith('/product/123')
147+
148+
mockPush.mockClear()
149+
window.STOREFRONT_PREVIEW?.experimentalUnsafeNavigate?.('/mybase/account', 'replace')
150+
expect(mockReplace).toHaveBeenCalledWith('/account')
151+
})
152+
153+
test('experimentalUnsafeNavigate does not remove when path does not start with base path', () => {
154+
;(detectStorefrontPreview as jest.Mock).mockReturnValue(true)
155+
156+
render(
157+
<StorefrontPreview
158+
enabled={true}
159+
getToken={() => 'my-token'}
160+
getBasePath={() => '/mybase'}
161+
/>
162+
)
163+
164+
window.STOREFRONT_PREVIEW?.experimentalUnsafeNavigate?.('/other/product/123', 'push')
165+
expect(mockPush).toHaveBeenCalledWith('/other/product/123')
166+
})
167+
168+
test('experimentalUnsafeNavigate does not strip when path has basePath only as substring (e.g. /shop vs /shopping/cart)', () => {
169+
;(detectStorefrontPreview as jest.Mock).mockReturnValue(true)
170+
171+
render(
172+
<StorefrontPreview
173+
enabled={true}
174+
getToken={() => 'my-token'}
175+
getBasePath={() => '/shop'}
176+
/>
177+
)
178+
179+
window.STOREFRONT_PREVIEW?.experimentalUnsafeNavigate?.('/shopping/cart', 'push')
180+
expect(mockPush).toHaveBeenCalledWith('/shopping/cart')
181+
})
182+
183+
test('experimentalUnsafeNavigate strips to / when path exactly equals basePath', () => {
184+
;(detectStorefrontPreview as jest.Mock).mockReturnValue(true)
185+
186+
render(
187+
<StorefrontPreview
188+
enabled={true}
189+
getToken={() => 'my-token'}
190+
getBasePath={() => '/mybase'}
191+
/>
192+
)
193+
194+
window.STOREFRONT_PREVIEW?.experimentalUnsafeNavigate?.('/mybase', 'push')
195+
expect(mockPush).toHaveBeenCalledWith('/')
196+
})
197+
198+
test('experimentalUnsafeNavigate removes base path from location object when getBasePath is provided', () => {
199+
;(detectStorefrontPreview as jest.Mock).mockReturnValue(true)
200+
201+
render(
202+
<StorefrontPreview
203+
enabled={true}
204+
getToken={() => 'my-token'}
205+
getBasePath={() => '/mybase'}
206+
/>
207+
)
208+
209+
window.STOREFRONT_PREVIEW?.experimentalUnsafeNavigate?.(
210+
{pathname: '/mybase/product/123', search: '?q=1'},
211+
'push'
212+
)
213+
expect(mockPush).toHaveBeenCalledWith({pathname: '/product/123', search: '?q=1'})
214+
})
215+
110216
test('cache breaker is added to the parameters of SCAPI requests, only if in storefront preview', () => {
111217
;(detectStorefrontPreview as jest.Mock).mockReturnValue(true)
112218
mockQueryEndpoint('baskets/123', {})

packages/commerce-sdk-react/src/components/StorefrontPreview/storefront-preview.tsx

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,57 @@ type GetToken = () => string | undefined | Promise<string | undefined>
1717
type ContextChangeHandler = () => void | Promise<void>
1818
type OptionalWhenDisabled<T> = ({enabled?: true} & T) | ({enabled: false} & Partial<T>)
1919

20+
/**
21+
* Remove the base path from a path string.
22+
* Only strips when path equals basePath or path starts with basePath + '/'.
23+
*/
24+
function removeBasePathFromPath(path: string, basePath: string): string {
25+
const matches =
26+
path.startsWith(basePath + '/') || path === basePath
27+
return matches ? path.slice(basePath.length) || '/' : path
28+
}
29+
30+
/**
31+
* Strip the base path from a path
32+
*
33+
* React Router history re-adds the base path to the path, so we
34+
* remove it here to avoid base path duplication.
35+
*/
36+
function removeBasePathFromLocation<T>(
37+
pathOrLocation: LocationDescriptor<T>,
38+
basePath: string
39+
): LocationDescriptor<T> {
40+
if (!basePath) return pathOrLocation
41+
if (typeof pathOrLocation === 'string') {
42+
return removeBasePathFromPath(pathOrLocation, basePath) as LocationDescriptor<T>
43+
}
44+
const pathname = pathOrLocation.pathname ?? '/'
45+
return {
46+
...pathOrLocation,
47+
pathname: removeBasePathFromPath(pathname, basePath)
48+
}
49+
}
50+
2051
/**
2152
*
2253
* @param enabled - flag to turn on/off Storefront Preview feature. By default, it is set to true.
2354
* This flag only applies if storefront is running in a Runtime Admin iframe.
2455
* @param getToken - A method that returns the access token for the current user
56+
* @param getBasePath - A method that returns the router base path of the app.
2557
*/
2658
export const StorefrontPreview = ({
2759
children,
2860
enabled = true,
2961
getToken,
30-
onContextChange
62+
onContextChange,
63+
getBasePath
3164
}: React.PropsWithChildren<
3265
// Props are only required when Storefront Preview is enabled
33-
OptionalWhenDisabled<{getToken: GetToken; onContextChange?: ContextChangeHandler}>
66+
OptionalWhenDisabled<{
67+
getToken: GetToken
68+
onContextChange?: ContextChangeHandler
69+
getBasePath?: () => string
70+
}>
3471
>) => {
3572
const history = useHistory()
3673
const isHostTrusted = detectStorefrontPreview()
@@ -39,6 +76,13 @@ export const StorefrontPreview = ({
3976

4077
useEffect(() => {
4178
if (enabled && isHostTrusted) {
79+
if (process.env.NODE_ENV !== 'production' && !getBasePath) {
80+
console.warn(
81+
'[StorefrontPreview] No getBasePath prop provided. ' +
82+
'If your app uses a base path for router routes (showBasePath is true in url config), ' +
83+
'pass getBasePath to avoid base path duplication during navigation.'
84+
)
85+
}
4286
window.STOREFRONT_PREVIEW = {
4387
...window.STOREFRONT_PREVIEW,
4488
getToken,
@@ -49,11 +93,13 @@ export const StorefrontPreview = ({
4993
action: 'push' | 'replace' = 'push',
5094
...args: unknown[]
5195
) => {
52-
history[action](path, ...args)
96+
const basePath = getBasePath?.() ?? ''
97+
const pathWithoutBase = removeBasePathFromLocation(path, basePath)
98+
history[action](pathWithoutBase, ...args)
5399
}
54100
}
55101
}
56-
}, [enabled, getToken, onContextChange, siteId])
102+
}, [enabled, getToken, onContextChange, siteId, getBasePath])
57103

58104
useEffect(() => {
59105
if (enabled && isHostTrusted) {
@@ -99,7 +145,8 @@ StorefrontPreview.propTypes = {
99145
// to get to a place where both these props are simply optional and we will provide default implementations.
100146
// This would make the API simpler to use.
101147
getToken: CustomPropTypes.requiredFunctionWhenEnabled,
102-
onContextChange: PropTypes.func
148+
onContextChange: PropTypes.func,
149+
getBasePath: PropTypes.func
103150
}
104151

105152
export default StorefrontPreview

packages/commerce-sdk-react/src/components/StorefrontPreview/utils.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ export const detectStorefrontPreview = () => {
2626
export const getClientScript = () => {
2727
const parentOrigin = getParentOrigin() ?? 'https://runtime.commercecloud.com'
2828
return parentOrigin === DEVELOPMENT_ORIGIN
29-
// TODO: This will need to be updated to support base paths with storefront preview
3029
? `${parentOrigin}${LOCAL_BUNDLE_PATH}/static/storefront-preview.js`
3130
: `${parentOrigin}/cc/b2c/preview/preview.client.js`
3231
}

packages/pwa-kit-runtime/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
## v3.16.0-dev (Dec 17, 2025)
22
- Move envBasePath into ssrParameters [#3590](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3590)
33
- Support adding base paths to shopper facing URLs [#3615](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3615)
4+
- Update storefront preview to support base paths [#3666](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3666)
45

56
## v3.15.0 (Dec 17, 2025)
67
- Fix multiple set-cookie headers [#3508](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3508)

packages/pwa-kit-runtime/src/ssr/server/build-remote-server.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -602,8 +602,9 @@ export const RemoteServerFactory = {
602602
return next()
603603
}
604604

605-
// For other routes, only proceed if path actually starts with base path
606-
if (!req.path.startsWith(basePath)) {
605+
// For other routes, only proceed if path equals basePath or path starts with basePath + '/'
606+
const pathMatchesBasePath = req.path === basePath || req.path.startsWith(basePath + '/')
607+
if (!pathMatchesBasePath) {
607608
return next()
608609
}
609610

packages/template-retail-react-app/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
- [Feature] One Click Checkout [#3552](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3552)
33
- Move envBasePath into ssrParameters [#3590](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3590)
44
- Support adding base paths to shopper facing URLs [#3615](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3615)
5+
- Update storefront preview to support base paths [#3666](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3666)
56
- [Feature] Add `fuzzyPathMatching` to reduce computational overhead of route generation at time of application load [#3530](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3530)
67
- [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)
78
- [Feature] PWA Integration with OMS

packages/template-retail-react-app/app/components/_app/index.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -303,7 +303,7 @@ const App = (props) => {
303303

304304
return (
305305
<Box className="sf-app" {...styles.container}>
306-
<StorefrontPreview getToken={getTokenWhenReady}>
306+
<StorefrontPreview getToken={getTokenWhenReady} getBasePath={getRouterBasePath}>
307307
<IntlProvider
308308
onError={(err) => {
309309
if (!messages) {

packages/template-retail-react-app/app/utils/site-utils.js

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,20 @@ export const getSiteByReference = (siteRef) => {
9595
)
9696
}
9797

98+
/**
99+
* Remove the base path from a path string only when path equals basePath or path starts with basePath + '/'.
100+
* @param {string} path - the path to strip
101+
* @param {string} basePath - the base path to remove
102+
* @returns {string} the path with base path removed, or the original path
103+
*/
104+
export const removeBasePathFromPath = (path, basePath) => {
105+
if (!basePath) return path
106+
if (path.startsWith(basePath + '/') || path === basePath) {
107+
return path.substring(basePath.length) || '/'
108+
}
109+
return path
110+
}
111+
98112
/**
99113
* This function return the identifiers (site and locale) from the given url
100114
* The site will always go before locale if both of them are presented in the pathname
@@ -107,9 +121,7 @@ export const getParamsFromPath = (path) => {
107121
// Remove the base path from the pathname if present since
108122
// it shifts the location of the site and locale in the pathname
109123
const basePath = getRouterBasePath()
110-
if (basePath && pathname.startsWith(basePath)) {
111-
pathname = pathname.substring(basePath.length)
112-
}
124+
pathname = removeBasePathFromPath(pathname, basePath)
113125

114126
const config = getConfig()
115127
const {pathMatcher, searchMatcherForSite, searchMatcherForLocale} = getConfigMatcher(config)

packages/template-retail-react-app/app/utils/site-utils.test.js

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ import {getRouterBasePath} from '@salesforce/pwa-kit-react-sdk/ssr/universal/uti
1616
import mockConfig from '@salesforce/retail-react-app/config/mocks/default'
1717
import {
1818
getParamsFromPath,
19-
resolveLocaleFromUrl
19+
resolveLocaleFromUrl,
20+
removeBasePathFromPath
2021
} from '@salesforce/retail-react-app/app/utils/site-utils'
2122
jest.mock('@salesforce/pwa-kit-runtime/utils/ssr-config', () => {
2223
const origin = jest.requireActual('@salesforce/pwa-kit-react-sdk/ssr/universal/utils')
@@ -341,6 +342,43 @@ describe('getParamsFromPath', function () {
341342
const result = getParamsFromPath(path)
342343
expect(result).toEqual({siteRef: 'us', localeRef: 'en-US'})
343344
})
345+
346+
test('should not strip when path has basePath only as substring (e.g. /shop vs /shopping/cart)', () => {
347+
const basePath = '/shop'
348+
getRouterBasePath.mockReturnValue(basePath)
349+
getConfig.mockImplementation(() => ({
350+
...mockConfig,
351+
app: {
352+
...mockConfig.app,
353+
url: {
354+
...mockConfig.app.url,
355+
showBasePath: true
356+
}
357+
}
358+
}))
359+
360+
const result = getParamsFromPath('/shopping/cart')
361+
expect(result).toBeDefined()
362+
})
363+
})
364+
})
365+
366+
describe('removeBasePathFromPath', () => {
367+
test('removes when path starts with basePath + "/"', () => {
368+
expect(removeBasePathFromPath('/shop/cart', '/shop')).toBe('/cart')
369+
expect(removeBasePathFromPath('/test-base/uk/en-GB/foo', '/test-base')).toBe(
370+
'/uk/en-GB/foo'
371+
)
372+
})
373+
test('removes to "/" when path exactly equals basePath', () => {
374+
expect(removeBasePathFromPath('/shop', '/shop')).toBe('/')
375+
})
376+
test('does not remove when basePath is only a substring (e.g. /shop vs /shopping/cart)', () => {
377+
expect(removeBasePathFromPath('/shopping/cart', '/shop')).toBe('/shopping/cart')
378+
expect(removeBasePathFromPath('/shopping', '/shop')).toBe('/shopping')
379+
})
380+
test('returns path unchanged when basePath is empty', () => {
381+
expect(removeBasePathFromPath('/any/path', '')).toBe('/any/path')
344382
})
345383
})
346384

0 commit comments

Comments
 (0)