Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions packages/commerce-sdk-react/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
## v5.2.0-dev
- Update storefront preview to support base paths [#3666](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3666)

## v5.1.0-dev
- Add Page Designer Support [#3727](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3727)
- Bump commerce-sdk-isomorphic to 5.1.0 [#3725](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3725)
Expand All @@ -7,6 +10,8 @@

## v5.0.0 (Feb 12, 2026)
- Upgrade to commerce-sdk-isomorphic v5.0.0 and introduce Payment Instrument SCAPI integration [#3552](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3552)

## 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)
- [Feature] Update `authorizePasswordless`, `getPasswordResetToken`, and `resetPassword` to support use of `email` mode [#3525](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3525)
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) {
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)
}
}
}
}, [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
4 changes: 4 additions & 0 deletions packages/pwa-kit-create-app/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## v3.18.0-dev
- 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.17.0-dev
- Add Salesforce Payments configuration to generated projects [#3725] (https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3725)
- Clear verdaccio npm cache during project generation [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,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 @@ -176,10 +182,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 @@ -48,13 +48,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
3 changes: 3 additions & 0 deletions packages/pwa-kit-dev/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
## v3.18.0-dev
- Move envBasePath into ssrParameters [#3590](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3590)

## v3.17.0-dev
- Add Page Designer Design CSS Support [#3727](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3727)
- Update jest, archiver and remove rimraf dependencies [#3663](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3663)
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
4 changes: 4 additions & 0 deletions packages/pwa-kit-react-sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## v3.18.0-dev
- 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.17.0-dev
- Update test setup for Jest 29 compatibility [#3663](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3663)
- Add Node 24 support. Drop Node 16 support [#3652](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3652)
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
Loading