Skip to content
Merged
Show file tree
Hide file tree
Changes from 41 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
bf3dc89
try using matchpath for allowlisting
yunakim714 Apr 21, 2025
ee053db
skip geturlmapping api call if path is found
yunakim714 Apr 21, 2025
75104c5
read config value matchingStrategy
yunakim714 Apr 29, 2025
330f896
Merge branch 'useBlock' into W-17543649-seo-allowlist-routes
yunakim714 Apr 29, 2025
060f04d
add config to default json
yunakim714 Apr 29, 2025
034834d
revert package lock
yunakim714 Apr 29, 2025
eb3159e
lint
yunakim714 Apr 29, 2025
6280b64
lint
yunakim714 Apr 29, 2025
a08eaaa
setup jest and add test cases
yunakim714 Apr 30, 2025
150fb0c
Merge branch 'useBlock' into W-17543649-seo-allowlist-routes
yunakim714 Apr 30, 2025
7f350dc
refactor seo to resolve react errors
yunakim714 Apr 30, 2025
a67efe3
Merge branch 'W-17543649-seo-allowlist-routes' of github.com:Salesfor…
yunakim714 Apr 30, 2025
047b4ac
Merge branch 'useBlock' into W-17543649-seo-allowlist-routes
yunakim714 Apr 30, 2025
c5db1a0
lint
yunakim714 Apr 30, 2025
a308fb1
fix matching logic
yunakim714 May 1, 2025
4c5e397
add comments, exit early
yunakim714 May 5, 2025
f836d94
update config naming
yunakim714 May 6, 2025
84214ea
fix naming
yunakim714 May 6, 2025
a6e6240
add config details in readme
yunakim714 May 6, 2025
89e73a4
Merge branch 'useBlock' into W-17543649-seo-allowlist-routes
yunakim714 May 6, 2025
b0d54cc
add warning if there are multiple wildcard routes
yunakim714 May 6, 2025
ddd8641
Merge branch 'W-17543649-seo-allowlist-routes' of github.com:Salesfor…
yunakim714 May 6, 2025
324b91f
move to correct readme file, add sample.json
yunakim714 May 6, 2025
6f73931
resolve merge conflicts
yunakim714 May 6, 2025
c759e50
resolve merge conflicts
yunakim714 May 6, 2025
365615b
resolve merge conflicts
yunakim714 May 6, 2025
2634f86
resolve merge conflicts
yunakim714 May 6, 2025
67e146a
move matchPath function to utils module
yunakim714 May 7, 2025
a812e24
add tests for matchpath
yunakim714 May 7, 2025
71f5c40
Fix tests
jeremy-jung1 May 7, 2025
bbc7563
Merge branch 'W-17543649-seo-allowlist-routes' of github.com:Salesfor…
jeremy-jung1 May 7, 2025
91d604b
cleanup
yunakim714 May 7, 2025
bd7762c
Merge branch 'W-17543649-seo-allowlist-routes' of github.com:Salesfor…
jeremy-jung1 May 7, 2025
e0d9221
Merge branch 'useBlock' into W-17543649-seo-allowlist-routes
yunakim714 May 7, 2025
5cf882f
cleanup
yunakim714 May 7, 2025
98aa1d9
Merge branch 'useBlock' into W-17543649-seo-allowlist-routes
yunakim714 May 8, 2025
5dcf5c6
resolve merge conflicts
yunakim714 May 9, 2025
23075cd
update package-lock
yunakim714 May 9, 2025
ba5a6f4
fix useblock test
yunakim714 May 12, 2025
6093098
Merge branch 'useBlock' into W-17543649-seo-allowlist-routes
yunakim714 May 12, 2025
66d3228
Merge branch 'useBlock' into W-17543649-seo-allowlist-routes
yunakim714 May 12, 2025
34fd298
fix comments and add test case
yunakim714 May 13, 2025
f6a0bc3
log warning for undefined paths
yunakim714 May 13, 2025
2f16eff
address comments
yunakim714 May 13, 2025
a0d4b18
remove console log
yunakim714 May 13, 2025
610912b
change matchpath return type
yunakim714 May 13, 2025
acea559
cleanup
yunakim714 May 13, 2025
b7a593f
Merge branch 'useBlock' into W-17543649-seo-allowlist-routes
yunakim714 May 13, 2025
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
36 changes: 25 additions & 11 deletions packages/extension-commerce-bm-seo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,23 +36,24 @@ The SEO extension is configured via the `mobify.app.extensions` property in your
[
"@salesforce/extension-commerce-bm-seo",
{
"enabled": true,
"commerceAPI": {
"enabled": true,
"routingMode": "router_first",
"commerceAPI": {
"proxyPath": "/mobify/proxy/api",
"parameters": {
"shortCode": "8o7m175y",
"clientId": "c9c45bfd-0ed3-4aa2-9971-40f88962b836",
"organizationId": "f_ecom_zzrf_001",
"siteId": "RefArchGlobal"
"shortCode": "8o7m175y",
"clientId": "c9c45bfd-0ed3-4aa2-9971-40f88962b836",
"organizationId": "f_ecom_zzrf_001",
"siteId": "RefArchGlobal"
}
},
"commerceAPIAuth": {
},
"commerceAPIAuth": {
"propertyNameInLocals": "commerceAPIAuth"
},
"resourceTypeToComponentMap": {
},
"resourceTypeToComponentMap": {
"category": "ProductList",
"product": "ProductDetail",
}
}
}
]
]
Expand All @@ -67,6 +68,7 @@ set `isNavigationBlocked` back to a state to allow the rendering of `<WrappedCom

### Configuration Options

- `routingMode`: Determines how the extension handles URL mapping
- `commerceAPI`: Settings for connecting to the Commerce API
- `proxyPath`: The proxy path for API requests
- `parameters`: Commerce API connection parameters
Expand All @@ -81,6 +83,18 @@ set `isNavigationBlocked` back to a state to allow the rendering of `<WrappedCom
- `product`: Component name for product pages
- `content_asset`: Component name for content assets

### Routing Mode Configuration

The `routingMode` configuration determines how the extension handles URL mapping and routing. There are two possible values:

- `"api_first"`: Always calls the `getUrlMapping` API to resolve URLs, regardless of whether the route is predefined in the application's route configuration. This mode ensures that all URLs are validated against the routes configured in Business Manager but may result in additional API calls.

- `"router_first"`: First checks if the URL matches a predefined route in the application's route configuration. If a match is found, it skips the `getUrlMapping` API call. This mode can improve performance by reducing API calls for known routes, but requires careful route configuration to ensure all valid URLs are properly handled.

Choose the routing mode based on your application's needs:
- Use `"api_first"` when you need to ensure all URLs are validated against the backend system
- Use `"router_first"` when you want to optimize performance by reducing API calls for known routes

## How It Works

The SEO extension works by:
Expand Down
1 change: 1 addition & 0 deletions packages/extension-commerce-bm-seo/config/default.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"enabled": true,
"routingMode": "api_first",
"commerceApi": {
"proxyPath": "/mobify/proxy/api",
"parameters": {
Expand Down
18 changes: 18 additions & 0 deletions packages/extension-commerce-bm-seo/config/sample.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"enabled": true,
"routingMode": "api_first",
"commerceApi": {
"proxyPath": "/mobify/proxy/api",
"parameters": {
"shortCode": "8o7m175y",
"clientId": "c9c45bfd-0ed3-4aa2-9971-40f88962b836",
"organizationId": "f_ecom_zzrf_001",
"siteId": "RefArchGlobal"
}
},
"resourceTypeToComponentMap": {
"category": "ProductList",
"product": "ProductDetail",
"content_asset": "ProductList"
}
}
2 changes: 1 addition & 1 deletion packages/extension-commerce-bm-seo/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion packages/extension-commerce-bm-seo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"lint:fix": "npm run lint:js -- --fix",
"lint:js": "pwa-kit-dev lint \"**/*.{js,jsx,ts,tsx}\"",
"start": "npm --prefix ./dev start",
"start:inspect": "npm --prefix ./dev run start:inspect"
"start:inspect": "npm --prefix ./dev run start:inspect",
"test": "pwa-kit-dev test"
},
"devDependencies": {
"@loadable/component": "^5.15.3",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jest.mock('../hooks/use-extension-config', () => ({
useExtensionConfig: jest.fn()
}))

// Mock the useApplicationExtensionsStore hook
// Mock useRoutes and useBlockNavigation
let mockSetIsNavigationBlocked: jest.Mock
jest.mock('@salesforce/pwa-kit-extension-sdk/react', () => {
mockSetIsNavigationBlocked = jest.fn()
Expand Down Expand Up @@ -115,6 +115,96 @@ describe('SeoHOC', () => {
})
})

describe('router_first strategy', () => {
afterAll(() => {
;(
jest.requireMock('@salesforce/commerce-sdk-react').useUrlMapping as jest.Mock
).mockImplementation(() => ({
refetch: mockRefetch
}))
})

it('should skip URL mapping when route is defined and strategy is router_first', () => {
const MockComponent = () => <div>Test Component</div>
const WrappedComponent = SeoHOC(MockComponent)
// Mock useExtensionConfig to return router_first strategy
;(useExtensionConfig as jest.Mock).mockReturnValue({
routingMode: 'router_first',
resourceTypeToComponentMap: {}
})

// Mock useRoutes to return predefined routes
const mockRoutes = [
{path: '/products/:id', component: MockComponent},
{path: '/category/:id', component: MockComponent},
{path: '*', component: MockComponent} // Catch-all route
]

;(
jest.requireMock('@salesforce/pwa-kit-react-sdk/ssr/universal/hooks')
.useRoutes as jest.Mock
).mockReturnValue({
routes: mockRoutes,
setRoutes: jest.fn()
})

// Mock useUrlMapping to ensure it's not called
const mockRefetch = jest.fn()
;(
jest.requireMock('@salesforce/commerce-sdk-react').useUrlMapping as jest.Mock
).mockReturnValue({
refetch: mockRefetch
})

render(
<BrowserRouter>
<WrappedComponent />
</BrowserRouter>
)

// Verify that the component renders without calling URL mapping
expect(screen.getByText('Test Component')).toBeInTheDocument()
expect(mockRefetch).not.toHaveBeenCalled()
})

it('should proceed with URL mapping when route is not defined and strategy is router_first', () => {
const MockComponent = () => <div>Test Component</div>
const WrappedComponent = SeoHOC(MockComponent)
// Mock useExtensionConfig to return router_first strategy
;(useExtensionConfig as jest.Mock).mockReturnValue({
routingMode: 'router_first',
resourceTypeToComponentMap: {}
})

// Mock useRoutes to return only catch-all route
const mockRoutes = [{path: '*', component: MockComponent}]
;(
jest.requireMock('@salesforce/pwa-kit-react-sdk/ssr/universal/hooks')
.useRoutes as jest.Mock
).mockReturnValue({
routes: mockRoutes,
setRoutes: jest.fn()
})

// Mock useUrlMapping to ensure it's called
const mockRefetch = jest.fn()
;(
jest.requireMock('@salesforce/commerce-sdk-react').useUrlMapping as jest.Mock
).mockReturnValue({
refetch: mockRefetch
})

render(
<BrowserRouter>
<WrappedComponent />
</BrowserRouter>
)

// Verify that URL mapping is called when route is not defined
expect(mockRefetch).toHaveBeenCalled()
})
})

describe('setRoutes and isNavigationBlocked call', () => {
it('renders the wrapped component and passes props', () => {
const {WrappedComponent} = setupForSetRoutesTests({pathname: '/another-path'})
Expand Down
29 changes: 25 additions & 4 deletions packages/extension-commerce-bm-seo/src/components/seo-hoc.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import {useUrlMapping} from '@salesforce/commerce-sdk-react'
import {useLocation, Redirect} from 'react-router-dom'
import {useApplicationExtensionsStore} from '@salesforce/pwa-kit-extension-sdk/react'
import {useExtensionConfig} from '../hooks/use-extension-config'
import {matchPath} from '../utils/route-match-utils'
import {ROUTING_MODE} from '../constants'

type SeoHOCProps = React.ComponentPropsWithoutRef<any>

interface UrlMappingResponse {
Expand All @@ -33,14 +36,22 @@ const seoHOC = <P extends object>(WrappedComponent: React.ComponentType<P>) => {
const SeoHOC: React.FC<P> = (props: SeoHOCProps) => {
const location = useLocation()
const {routes, setRoutes} = useRoutes()
const {resourceTypeToComponentMap} = useExtensionConfig()
const {resourceTypeToComponentMap, routingMode} = useExtensionConfig()
const [urlSegment, setUrlSegment] = useState(location.pathname)
const {setIsNavigationBlocked, siteLocale} = useApplicationExtensionsStore((state) => {
return state.state['@salesforce/extension-commerce-bm-seo']
})

const resolveRef = useRef<(result?: object) => void>()

// The `routingMode` configuration determines whether we check the ROUTER (AKA the predefined route config) first or the `getUrlMapping` API
// ROUTING_MODE.ROUTER_FIRST: if `location.pathname` matches a predefined route, skip the `getUrlMapping` API call
// ROUTING_MODE.API_FIRST: always call `getUrlMapping`
const skipMappingCall =
routingMode === ROUTING_MODE.ROUTER_FIRST &&
matchPath(location.pathname, routes, {filterWildcardRoutes: true})

// Disabling the hook on render so it's only called when refetch is called

const {refetch} = useUrlMapping(
{
parameters: {
Expand All @@ -55,7 +66,12 @@ const seoHOC = <P extends object>(WrappedComponent: React.ComponentType<P>) => {

useEffect(() => {
const fetchData = async () => {
if (!urlSegment) return
if (!urlSegment) {
return
}
if (skipMappingCall) {
return
}
const result = await refetch()
if (!resolveRef.current) return
if (!result || result.status === 'error') {
Expand All @@ -69,10 +85,15 @@ const seoHOC = <P extends object>(WrappedComponent: React.ComponentType<P>) => {
}
}
void fetchData().catch(console.error)
}, [urlSegment])
}, [urlSegment, skipMappingCall])

const {isBlocked: isNavigationBlocked} = useBlockNavigation(
async (location: Location, _: string) => {
// Early exit if configured to check the Router Context first and found a matching route
if (skipMappingCall) {
return
}

const urlMappingResponse = await new Promise<UrlMappingResponse | undefined>(
(resolve, __) => {
const nextSegment = location.pathname
Expand Down
11 changes: 11 additions & 0 deletions packages/extension-commerce-bm-seo/src/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/*
* Copyright (c) 2025, Salesforce, Inc.
* All rights reserved.
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/

export const ROUTING_MODE = {
ROUTER_FIRST: 'router_first',
API_FIRST: 'api_first'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* Copyright (c) 2025, salesforce.com, inc.
* All rights reserved.
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/

import {matchPath} from './route-match-utils'

describe('matchPath', () => {
const NullComponent = () => null
const routes = [
{path: '/', component: NullComponent},
{path: '/about', component: NullComponent},
{path: '/contact', component: NullComponent},
{path: '/products/*', component: NullComponent},
{path: '/products/:id', component: NullComponent},
{path: '*', component: NullComponent}
]

it('should return the matching route', () => {
const result = matchPath('/about', routes)
expect(result).toEqual({path: '/about', component: NullComponent})
})

it('should return the matching route with wildcard if filterWildcardRoutes is false', () => {
const result = matchPath('/products/123', routes)
expect(result).toEqual({path: '/products/*', component: NullComponent})
})

it('should return the matching route without wildcard if filterWildcardRoutes is true', () => {
const result = matchPath('/products/123', routes, {filterWildcardRoutes: true})
expect(result).toEqual({path: '/products/:id', component: NullComponent})
})

it('should return undefined if no match is found and filterWildcardRoutes is true', () => {
const result = matchPath('/none', routes, {filterWildcardRoutes: true})
expect(result).toBeUndefined()
})
})
43 changes: 43 additions & 0 deletions packages/extension-commerce-bm-seo/src/utils/route-match-utils.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*
* Copyright (c) 2025, salesforce.com, inc.
* All rights reserved.
* SPDX-License-Identifier: BSD-3-Clause
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
*/
import {matchPath as matchPathReactRouter} from 'react-router-dom'
import {RouteProps} from '@salesforce/pwa-kit-extension-sdk/types'

/**
* Checks whether the given URL path matches a predefined route defined in the application's routes config.
* Optionally filters out wildcard routes and warns if multiple wildcard routes are detected.
* @param pathname - The URL path to check
* @param routes - Array of route configurations to check against
* @param options - Optional configuration for filtering wildcard routes
* @returns The matching route object or undefined if no match is found
*/
export const matchPath = (
pathname: string,
routes: RouteProps[],
options?: {filterWildcardRoutes: boolean}
): {path: string} | undefined => {
let validRoutes = routes
// Filter out routes ending with a wildcard if the option is set
if (options?.filterWildcardRoutes) {
const wildcardRoutes = routes.filter((route) => route.path.endsWith('*'))
if (wildcardRoutes.length > 1) {
console.warn(
`Multiple wildcard routes detected (${wildcardRoutes.length}). This may cause unexpected routing behavior. Wildcard routes:`,
wildcardRoutes.map((route) => route.path)
)
}
validRoutes = routes.filter((route) => !route.path.endsWith('*'))
}

const routeMatch = validRoutes.find(({path}) =>
matchPathReactRouter(pathname, {
path,
exact: true
})
)
return routeMatch
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ describe('useBlockNavigation', () => {

it('should call the callback and push navigation after callback resolves', async () => {
const callback = jest.fn().mockResolvedValue(undefined)
render(<TestComponent callback={callback} />)
const {unmount} = render(<TestComponent callback={callback} />)
Copy link
Contributor

Choose a reason for hiding this comment

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

Was this just a linting thing?


// Simulate navigation to a new location
await act(async () => {
Expand Down
Loading