Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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
2 changes: 2 additions & 0 deletions packages/commerce-sdk-react/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
## v5.0.0-dev (Jan 28, 2026)
- 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)
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
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 @@ -39,6 +76,13 @@ export const StorefrontPreview = ({

useEffect(() => {
if (enabled && isHostTrusted) {
if (process.env.NODE_ENV !== 'production' && !getBasePath) {
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: Add a guard to check if basePath is enabled showBasePath so we avoid printing the warning on all sites that don't use basePath

console.warn(
'[StorefrontPreview] No getBasePath prop provided. ' +
'If your app uses a base path for router routes (showBasePath is true in url config), ' +
'pass getBasePath to avoid base path duplication during navigation.'
)
}
window.STOREFRONT_PREVIEW = {
...window.STOREFRONT_PREVIEW,
getToken,
Expand All @@ -49,11 +93,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)
Copy link
Contributor

Choose a reason for hiding this comment

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

why do we need to exclude basePath in this navigate? (I probably missed the context of this)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This change was a part of #3666 .

The history API is a part of react router so when it is invoked with a path, react router will automatically append the base path (set as the basename prop on the Router).

If we don't remove the base path, then react router will add a duplicate base path.

It's the same issue that we fixed in sf-next in this PR: https://github.com/commerce-emu/storefront-next/pull/1050

}
}
}
}, [enabled, getToken, onContextChange, siteId])
}, [enabled, getToken, onContextChange, siteId, getBasePath])

useEffect(() => {
if (enabled && isHostTrusted) {
Expand Down Expand Up @@ -99,7 +145,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
2 changes: 2 additions & 0 deletions packages/pwa-kit-create-app/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
## 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)
- Add new One-Click Checkout configuration [#3609](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3609)
- Support email mode by default for passwordless login and password reset in a generated app. [#3525](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3525)
- Util function for passwordless callback URI [#3630](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3630)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,19 @@ module.exports = {
locale: 'path',
// This boolean value dictates whether or not default site or locale values are shown in the url. Defaults to: false
showDefaults: true,
// This boolean value dictates whether or not the base path, defined in ssrParameters.envBasePath,
// is shown in shopper facing urls. Defaults to: false
showBasePath: false,
{{else}}
// Determine where the siteRef is located. Valid values include 'path|query_param|none'. Defaults to: 'none'
site: 'none',
// Determine where the localeRef is located. Valid values include 'path|query_param|none'. Defaults to: 'none'
locale: 'none',
// This boolean value dictates whether or not default site or locale values are shown in the url. Defaults to: false
showDefaults: false,
// This boolean value dictates whether or not the base path, defined in ssrParameters.envBasePath,
// is shown in shopper facing urls. Defaults to: false
showBasePath: false,
{{/if}}
// This boolean value dictates whether the plus sign (+) is interpreted as space for query param string. Defaults to: false
interpretPlusSignAsSpace: false
Expand Down Expand Up @@ -157,10 +163,6 @@ module.exports = {
apiKey: process.env.GOOGLE_CLOUD_API_KEY
}
},
// Experimental: The base path for the app. This is the path that will be prepended to all /mobify routes,
// callback routes, and Express routes.
// Setting this to `/` or an empty string will result in the above routes not having a base path.
envBasePath: '/',
// This list contains server-side only libraries that you don't want to be compiled by webpack
externals: [],
// Page not found url for your app
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,19 @@ module.exports = {
locale: 'path',
// This boolean value dictates whether default site or locale values are shown in the url. Defaults to: false
showDefaults: true,
// This boolean value dictates whether or not the base path, defined in ssrParameters.envBasePath,
// is shown in shopper facing urls. Defaults to: false
showBasePath: false,
{{else}}
// Determine where the siteRef is located. Valid values include 'path|query_param|none'. Defaults to: 'none'
site: 'none',
// Determine where the localeRef is located. Valid values include 'path|query_param|none'. Defaults to: 'none'
locale: 'none',
// This boolean value dictates whether or not default site or locale values are shown in the url. Defaults to: false
showDefaults: false,
// This boolean value dictates whether or not the base path, defined in ssrParameters.envBasePath,
// is shown in shopper facing urls. Defaults to: false
showBasePath: false,
{{/if}}
// This boolean value dictates whether the plus sign (+) is interpreted as space for query param string. Defaults to: false
interpretPlusSignAsSpace: false
Expand Down
2 changes: 2 additions & 0 deletions packages/pwa-kit-dev/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
## v3.16.0-dev (Dec 17, 2025)
- Move envBasePath into ssrParameters [#3590](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3590)

## v3.15.0 (Dec 17, 2025)

## v3.14.0 (Nov 04, 2025)
Expand Down
8 changes: 7 additions & 1 deletion packages/pwa-kit-dev/bin/pwa-kit-dev.js
Original file line number Diff line number Diff line change
Expand Up @@ -254,10 +254,16 @@ const main = async () => {
process.exit(1)
}

// Load config to get envBasePath from ssrParameters for local development
// This mimics how MRT sets the MRT_ENV_BASE_PATH system environment variable
const config = getConfig() || {}
const envBasePath = config.ssrParameters?.envBasePath || ''

execSync(`${babelNode} ${inspect ? '--inspect' : ''} ${babelArgs} ${entrypoint}`, {
env: {
...process.env,
...(noHMR ? {HMR: 'false'} : {})
...(noHMR ? {HMR: 'false'} : {}),
...(envBasePath ? {MRT_ENV_BASE_PATH: envBasePath} : {})
}
})
})
Expand Down
3 changes: 3 additions & 0 deletions packages/pwa-kit-react-sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +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)

## v3.15.0 (Dec 17, 2025)

## v3.14.0 (Nov 04, 2025)
Expand Down
4 changes: 3 additions & 1 deletion packages/pwa-kit-react-sdk/src/ssr/browser/main.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {loadableReady} from '@loadable/component'
import {uuidv4} from '../../utils/uuidv4.client'
import PropTypes from 'prop-types'
import logger from '../../utils/logger-instance'
import {getRouterBasePath} from '../universal/utils'

/* istanbul ignore next */
export const registerServiceWorker = (url) => {
Expand Down Expand Up @@ -44,10 +45,11 @@ export const registerServiceWorker = (url) => {
export const OuterApp = ({routes, error, WrappedApp, locals, onHydrate}) => {
const AppConfig = getAppConfig()
const isInitialPageRef = useRef(true)
const routerBasename = getRouterBasePath() || undefined

return (
<ServerContext.Provider value={{}}>
<Router ref={onHydrate}>
<Router ref={onHydrate} basename={routerBasename}>
<CorrelationIdProvider
correlationId={() => {
// If we are hydrating an error page use the server correlation id.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import logger from '../../utils/logger-instance'
import PerformanceTimer from '../../utils/performance'
import {PERFORMANCE_MARKS} from '../../utils/performance-marks'

import {getRouterBasePath} from '../universal/utils'

const CWD = process.cwd()
const BUNDLES_PATH = path.resolve(CWD, 'build/loadable-stats.json')

Expand Down Expand Up @@ -270,9 +272,11 @@ export const render = (req, res, next) => {

const OuterApp = ({req, res, error, App, appState, routes, routerContext, location}) => {
const AppConfig = getAppConfig()
const routerBasename = getRouterBasePath() || undefined

return (
<ServerContext.Provider value={{req, res}}>
<Router location={location} context={routerContext}>
<Router location={location} context={routerContext} basename={routerBasename}>
<CorrelationIdProvider
correlationId={res.locals.requestId}
resetOnPageChange={false}
Expand Down Expand Up @@ -365,6 +369,7 @@ const renderApp = (args) => {
__CONFIG__: config,
__PRELOADED_STATE__: appState,
__ERROR__: error,
__MRT_ENV_BASE_PATH__: process.env.MRT_ENV_BASE_PATH || '',
// `window.Progressive` has a long history at Mobify and some
// client-side code depends on it. Maintain its name out of tradition.
Progressive: getWindowProgressive(req, res)
Expand Down
Loading