diff --git a/packages/extension-commerce-bm-seo/README.md b/packages/extension-commerce-bm-seo/README.md index 2295eed0c1..eec2a9f8cf 100644 --- a/packages/extension-commerce-bm-seo/README.md +++ b/packages/extension-commerce-bm-seo/README.md @@ -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", - } + } } ] ] @@ -67,6 +68,7 @@ set `isNavigationBlocked` back to a state to allow the rendering of ` ({ 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() @@ -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 = () =>
Test Component
+ 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( + + + + ) + + // 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 = () =>
Test Component
+ 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( + + + + ) + + // 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'}) diff --git a/packages/extension-commerce-bm-seo/src/components/seo-hoc.tsx b/packages/extension-commerce-bm-seo/src/components/seo-hoc.tsx index 3967259d96..2e6ebe89b4 100644 --- a/packages/extension-commerce-bm-seo/src/components/seo-hoc.tsx +++ b/packages/extension-commerce-bm-seo/src/components/seo-hoc.tsx @@ -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 interface UrlMappingResponse { @@ -33,13 +36,18 @@ const seoHOC =

(WrappedComponent: React.ComponentType

) => { const SeoHOC: React.FC

= (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>() + + // If routingMode is "router_first" and a predefined route matches, skip the getUrlMapping API call. + 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( { @@ -55,7 +63,12 @@ const seoHOC =

(WrappedComponent: React.ComponentType

) => { 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') { @@ -69,10 +82,15 @@ const seoHOC =

(WrappedComponent: React.ComponentType

) => { } } 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( (resolve, __) => { const nextSegment = location.pathname diff --git a/packages/extension-commerce-bm-seo/src/constants.ts b/packages/extension-commerce-bm-seo/src/constants.ts new file mode 100644 index 0000000000..130c83d013 --- /dev/null +++ b/packages/extension-commerce-bm-seo/src/constants.ts @@ -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 enum ROUTING_MODE { + ROUTER_FIRST = 'router_first', + API_FIRST = 'api_first' +} diff --git a/packages/extension-commerce-bm-seo/src/utils/route-match-utils.test.js b/packages/extension-commerce-bm-seo/src/utils/route-match-utils.test.js new file mode 100644 index 0000000000..50a337b4ef --- /dev/null +++ b/packages/extension-commerce-bm-seo/src/utils/route-match-utils.test.js @@ -0,0 +1,70 @@ +/* + * 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} + ] + const routesWithUndefined = [ + {path: '/', component: NullComponent}, + {path: undefined, component: NullComponent}, + {path: '/about', component: NullComponent} + ] + + it('should return the matching route', () => { + const result = matchPath('/about', routes) + expect(result).toEqual({path: '/about', isExact: true, params: {}, url: '/about'}) + }) + + it('should return the matching route with wildcard if filterWildcardRoutes is false', () => { + const result = matchPath('/products/123', routes) + expect(result).toEqual({path: '/products/*', isExact: true, params: {"0": "123"}, url: '/products/123'}) + }) + + 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', isExact: true, params: {id: "123"}, url: '/products/123'}) + }) + + it('should return undefined if no match is found and filterWildcardRoutes is true', () => { + const result = matchPath('/none', routes, {filterWildcardRoutes: true}) + expect(result).toBeNull() + }) + + it('should return undefined for an undefined path', () => { + const result = matchPath(undefined, routes) + expect(result).toBeNull() + }) + + it('should ignore undefined paths in the routes array', () => { + const result = matchPath('/about', routesWithUndefined, {filterWildcardRoutes: true}) + expect(result).toEqual({path: '/about', isExact: true, params: {}, url: '/about'}) + }) + + it('should ignore undefined paths in the routes array when filterWildcardRoutes is false', () => { + const result = matchPath('/about', routesWithUndefined) + expect(result).toEqual({path: '/about', isExact: true, params: {}, url: '/about'}) + }) + + it('should log a warning for undefined paths in the routes array', () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}) + matchPath('/about', routesWithUndefined, {filterWildcardRoutes: true}) + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Undefined paths detected'), + expect.arrayContaining([expect.objectContaining({path: undefined})]) + ) + consoleWarnSpy.mockRestore() + }) +}) diff --git a/packages/extension-commerce-bm-seo/src/utils/route-match-utils.ts b/packages/extension-commerce-bm-seo/src/utils/route-match-utils.ts new file mode 100644 index 0000000000..c5408df050 --- /dev/null +++ b/packages/extension-commerce-bm-seo/src/utils/route-match-utils.ts @@ -0,0 +1,55 @@ +/* + * 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' + +type Match = ReturnType> + +/** + * This is an enhanced version of matchPath that allows you to match to multiple routes as well as allowing you to filter out wildcard routes. + * @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} +): Match | null => { + let validRoutes = routes + // Check for undefined paths and log a warning + const undefinedPaths = routes.filter((route) => route.path === undefined) + if (undefinedPaths.length > 0) { + console.warn( + `Undefined paths detected (${undefinedPaths.length}). This may cause unexpected routing behavior. Undefined paths:`, + undefinedPaths + ) + } + + // 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('*')) + } + + for (const {path} of validRoutes) { + const match = matchPathReactRouter(pathname, { + path, + exact: true, + }) + if (match) return match + } + + return null +} diff --git a/packages/pwa-kit-extension-sdk/src/express/middleware/apply-application-extensions.test.ts b/packages/pwa-kit-extension-sdk/src/express/middleware/apply-application-extensions.test.ts index 33a9758c33..4422492faf 100644 --- a/packages/pwa-kit-extension-sdk/src/express/middleware/apply-application-extensions.test.ts +++ b/packages/pwa-kit-extension-sdk/src/express/middleware/apply-application-extensions.test.ts @@ -34,7 +34,6 @@ describe('applyApplicationExtensions Middleware with Express App', () => { ;(getApplicationExtensions as jest.Mock).mockReturnValue([]) createAppWithMiddleware() - // eslint-disable-next-line @typescript-eslint/no-misused-promises const response = await supertest(app).get('/test') expect(response.status).toBe(200) @@ -46,7 +45,6 @@ describe('applyApplicationExtensions Middleware with Express App', () => { ;(getApplicationExtensions as jest.Mock).mockReturnValue(mockExtensions) createAppWithMiddleware() - // eslint-disable-next-line @typescript-eslint/no-misused-promises const response = await supertest(app).get('/test') expect(response.status).toBe(200) @@ -62,7 +60,6 @@ describe('applyApplicationExtensions Middleware with Express App', () => { ;(getApplicationExtensions as jest.Mock).mockReturnValue([mockExtension]) createAppWithMiddleware() - // eslint-disable-next-line @typescript-eslint/no-misused-promises const response = await supertest(app).get('/test') expect(mockExtension.isEnabled).toHaveBeenCalled() @@ -83,7 +80,6 @@ describe('applyApplicationExtensions Middleware with Express App', () => { ;(getApplicationExtensions as jest.Mock).mockReturnValue([firstExtension, secondExtension]) createAppWithMiddleware() - // eslint-disable-next-line @typescript-eslint/no-misused-promises const response = await supertest(app).get('/test') expect(firstExtension.extendApp).toHaveBeenCalledWith(app)