Skip to content

Commit a800235

Browse files
authored
Merge pull request #3530 from SalesforceCommerceCloud/bendvc/W-20607771_add-fuzzy-path-matching
@@W-20607771@@ Add `fuzzyPathMatching` option to optimize route configuration
2 parents 972437e + 112d1d4 commit a800235

File tree

4 files changed

+311
-14
lines changed

4 files changed

+311
-14
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
## v8.4.0-dev (Dec 17, 2025)
2+
- [Feature] Add `fuzzyPathMatching` to reduce computational overhead of route generation at time of application load [#3530](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3530)
23

34
## v8.3.0 (Dec 17, 2025)
45
- [Bugfix] Fix Forgot Password link not working from Account Profile password update form [#3493](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/3493)

packages/template-retail-react-app/app/routes.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ export const routes = [
144144
export default () => {
145145
const config = getConfig()
146146
return configureRoutes(routes, config, {
147-
ignoredRoutes: ['/callback', '*']
147+
ignoredRoutes: ['/callback', '*'],
148+
fuzzyPathMatching: true
148149
})
149150
}

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

Lines changed: 131 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,25 +9,99 @@ import {getSites} from '@salesforce/retail-react-app/app/utils/site-utils'
99
import {urlPartPositions} from '@salesforce/retail-react-app/app/constants'
1010

1111
/**
12-
* Construct literal routes based on url config
13-
* with site and locale references (ids and aliases) from each in your application config
12+
* Build regex patterns for site and locale route parameters.
13+
* Creates patterns like "siteA|siteB|siteC" from all valid site/locale refs.
14+
*
15+
* @param {array} allSites - array of site configurations
16+
* @returns {object} - { sitePattern, localePattern }
17+
*/
18+
const buildRoutePatterns = (allSites) => {
19+
const siteRefs = allSites.flatMap((site) => [site.alias, site.id]).filter(Boolean)
20+
21+
const localeRefs = allSites
22+
.flatMap((site) => site.l10n.supportedLocales)
23+
.flatMap((locale) => [locale.alias, locale.id])
24+
.filter(Boolean)
25+
26+
// Remove duplicates and join into regex pattern
27+
const sitePattern = [...new Set(siteRefs)].join('|')
28+
const localePattern = [...new Set(localeRefs)].join('|')
29+
30+
return {sitePattern, localePattern}
31+
}
32+
33+
/**
34+
* Configure routes using parameterized paths with regex constraints.
35+
* This approach generates fewer routes by using patterns like:
36+
* /:site(siteA|siteB)/:locale(en|fr)/path
37+
*
38+
* Note: This may match site/locale combinations that aren't valid together
39+
* (e.g., a locale not supported by a specific site). Runtime validation
40+
* should be performed after route matching.
1441
*
1542
* @param {array} routes - array of routes to be reconstructed
16-
* @param {object} urlConfig
17-
* @param {object} options - options if there are any
18-
* @param {array} options.ignoredRoutes - routes that does not need be reconstructed
19-
* @return {array} - list of routes objects that has site and locale refs
43+
* @param {object} urlConfig - url configuration with site/locale positions
44+
* @param {array} allSites - array of site configurations
45+
* @param {array} ignoredRoutes - routes that should not be reconstructed
46+
* @returns {array} - list of parameterized route objects
2047
*/
21-
export const configureRoutes = (routes = [], config, {ignoredRoutes = []}) => {
22-
if (!routes.length) return []
23-
if (!config) return routes
48+
const configureRoutesWithFuzzyMatching = (routes, urlConfig, allSites, ignoredRoutes) => {
49+
const {sitePattern, localePattern} = buildRoutePatterns(allSites)
50+
const {locale: localePosition, site: sitePosition} = urlConfig
2451

25-
const {url: urlConfig} = config.app
52+
const outputRoutes = []
2653

27-
const allSites = getSites()
28-
if (!allSites) return routes
54+
for (const route of routes) {
55+
const {path, ...rest} = route
56+
57+
if (ignoredRoutes.includes(path)) {
58+
outputRoutes.push(route)
59+
continue
60+
}
61+
62+
if (localePosition === urlPartPositions.PATH && sitePosition === urlPartPositions.PATH) {
63+
// Both site and locale in path
64+
outputRoutes.push({
65+
path: `/:site(${sitePattern})/:locale(${localePattern})${path}`,
66+
...rest
67+
})
68+
} else if (sitePosition === urlPartPositions.PATH) {
69+
// Site only in path
70+
outputRoutes.push({
71+
path: `/:site(${sitePattern})${path}`,
72+
...rest
73+
})
74+
} else if (localePosition === urlPartPositions.PATH) {
75+
// Locale only in path
76+
outputRoutes.push({
77+
path: `/:locale(${localePattern})${path}`,
78+
...rest
79+
})
80+
}
81+
82+
// Original route as fallback
83+
outputRoutes.push(route)
84+
}
85+
86+
return outputRoutes
87+
}
88+
89+
/**
90+
* Configure routes using explicit paths for each site/locale combination.
91+
* This is the original approach that generates literal routes like:
92+
* /siteA/en/path, /siteA/fr/path, /siteB/en/path, etc.
93+
*
94+
* @param {array} routes - array of routes to be reconstructed
95+
* @param {object} urlConfig - url configuration with site/locale positions
96+
* @param {array} allSites - array of site configurations
97+
* @param {array} ignoredRoutes - routes that should not be reconstructed
98+
* @returns {array} - list of explicit route objects
99+
*/
100+
const configureRoutesWithExplicitMatching = (routes, urlConfig, allSites, ignoredRoutes) => {
101+
const {locale: localePosition, site: sitePosition} = urlConfig
29102

30103
let outputRoutes = []
104+
31105
for (let i = 0; i < routes.length; i++) {
32106
const {path, ...rest} = routes[i]
33107

@@ -44,7 +118,6 @@ export const configureRoutes = (routes = [], config, {ignoredRoutes = []}) => {
44118
localeRefs.push(locale.id)
45119
})
46120
localeRefs = localeRefs.filter(Boolean)
47-
const {locale: localePosition, site: sitePosition} = urlConfig
48121

49122
if (
50123
localePosition === urlPartPositions.PATH &&
@@ -100,12 +173,57 @@ export const configureRoutes = (routes = [], config, {ignoredRoutes = []}) => {
100173
outputRoutes.push(routes[i])
101174
}
102175
}
176+
103177
// Remove any duplicate routes
104178
outputRoutes = outputRoutes.reduce((res, route) => {
105179
if (!res.some(({path}) => path === route.path)) {
106180
res.push(route)
107181
}
108182
return res
109183
}, [])
184+
185+
return outputRoutes
186+
}
187+
188+
/**
189+
* Construct routes based on url config with site and locale references
190+
* (ids and aliases) from each site in your application config.
191+
*
192+
* @param {array} routes - array of routes to be reconstructed
193+
* @param {object} config - application configuration
194+
* @param {object} options - options if there are any
195+
* @param {array} options.ignoredRoutes - routes that should not be reconstructed
196+
* @param {boolean} options.fuzzyPathMatching - when true, uses parameterized routes with
197+
* regex constraints (e.g., /:site(a|b)/:locale(en|fr)/path) for fewer, more efficient
198+
* route configurations. When false (default), generates explicit routes for each
199+
* site/locale combination. Fuzzy matching may match invalid site/locale combinations
200+
* that require runtime validation.
201+
* @returns {array} - list of route objects with site and locale refs
202+
*/
203+
export const configureRoutes = (
204+
routes = [],
205+
config,
206+
{ignoredRoutes = [], fuzzyPathMatching = false}
207+
) => {
208+
if (!routes.length) return []
209+
if (!config) return routes
210+
211+
let outputRoutes = []
212+
const {url: urlConfig} = config.app
213+
214+
const allSites = getSites()
215+
if (!allSites) return routes
216+
217+
if (fuzzyPathMatching) {
218+
outputRoutes = configureRoutesWithFuzzyMatching(routes, urlConfig, allSites, ignoredRoutes)
219+
} else {
220+
outputRoutes = configureRoutesWithExplicitMatching(
221+
routes,
222+
urlConfig,
223+
allSites,
224+
ignoredRoutes
225+
)
226+
}
227+
110228
return outputRoutes
111229
}

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

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,4 +288,181 @@ describe('configureRoutes', function () {
288288
expect(paths).toEqual(expectedRes)
289289
})
290290
})
291+
292+
describe('fuzzyPathMatching', function () {
293+
// Expected patterns based on sites mock:
294+
// Sites: uk (alias) / site-1 (id), us (alias) / site-2 (id)
295+
// Locales: en-GB, fr (alias), fr-FR, it-IT (site-1), en-US, en-CA (site-2)
296+
const sitePattern = 'uk|site-1|us|site-2'
297+
const localePattern = 'en-GB|fr|fr-FR|it-IT|en-US|en-CA'
298+
299+
const fuzzyCases = [
300+
{
301+
urlConfig: {
302+
site: 'path',
303+
locale: 'path',
304+
showDefaults: true
305+
},
306+
expectedRes: [
307+
`/:site(${sitePattern})/:locale(${localePattern})/`,
308+
'/',
309+
`/:site(${sitePattern})/:locale(${localePattern})/category/:categoryId`,
310+
'/category/:categoryId'
311+
]
312+
},
313+
{
314+
urlConfig: {
315+
site: 'path',
316+
locale: 'path',
317+
showDefaults: false
318+
},
319+
expectedRes: [
320+
`/:site(${sitePattern})/:locale(${localePattern})/`,
321+
'/',
322+
`/:site(${sitePattern})/:locale(${localePattern})/category/:categoryId`,
323+
'/category/:categoryId'
324+
]
325+
},
326+
{
327+
urlConfig: {
328+
site: 'query_param',
329+
locale: 'path',
330+
showDefaults: true
331+
},
332+
expectedRes: [
333+
`/:locale(${localePattern})/`,
334+
'/',
335+
`/:locale(${localePattern})/category/:categoryId`,
336+
'/category/:categoryId'
337+
]
338+
},
339+
{
340+
urlConfig: {
341+
site: 'query_param',
342+
locale: 'path',
343+
showDefaults: false
344+
},
345+
expectedRes: [
346+
`/:locale(${localePattern})/`,
347+
'/',
348+
`/:locale(${localePattern})/category/:categoryId`,
349+
'/category/:categoryId'
350+
]
351+
},
352+
{
353+
urlConfig: {
354+
site: 'path',
355+
locale: 'query_param',
356+
showDefaults: true
357+
},
358+
expectedRes: [
359+
`/:site(${sitePattern})/`,
360+
'/',
361+
`/:site(${sitePattern})/category/:categoryId`,
362+
'/category/:categoryId'
363+
]
364+
},
365+
{
366+
urlConfig: {
367+
site: 'path',
368+
locale: 'query_param',
369+
showDefaults: false
370+
},
371+
expectedRes: [
372+
`/:site(${sitePattern})/`,
373+
'/',
374+
`/:site(${sitePattern})/category/:categoryId`,
375+
'/category/:categoryId'
376+
]
377+
},
378+
{
379+
urlConfig: {
380+
site: 'query_param',
381+
locale: 'query_param',
382+
showDefaults: true
383+
},
384+
expectedRes: ['/', '/category/:categoryId']
385+
},
386+
{
387+
urlConfig: {
388+
site: 'query_param',
389+
locale: 'query_param',
390+
showDefaults: false
391+
},
392+
expectedRes: ['/', '/category/:categoryId']
393+
},
394+
{
395+
urlConfig: {
396+
site: 'path',
397+
locale: 'path',
398+
showDefaults: true
399+
},
400+
expectedRes: [
401+
`/:site(${sitePattern})/:locale(${localePattern})/`,
402+
'/',
403+
'/category/:categoryId'
404+
],
405+
ignoredRoutes: ['/category/:categoryId']
406+
}
407+
]
408+
409+
fuzzyCases.forEach(({urlConfig, expectedRes, ignoredRoutes = []}) => {
410+
test(`Should return parameterized routes with fuzzyPathMatching based on ${JSON.stringify(
411+
urlConfig
412+
)} config${
413+
ignoredRoutes.length ? ` and ignore routes ${ignoredRoutes.join(',')}` : ''
414+
}`, () => {
415+
const config = {
416+
app: {
417+
url: urlConfig
418+
}
419+
}
420+
const configuredRoutes = configureRoutes(routes, config, {
421+
ignoredRoutes,
422+
fuzzyPathMatching: true
423+
})
424+
const paths = configuredRoutes.map((route) => route.path)
425+
expect(paths).toEqual(expectedRes)
426+
})
427+
})
428+
429+
test('Should generate significantly fewer routes with fuzzyPathMatching enabled', () => {
430+
const config = {
431+
app: {
432+
url: {
433+
site: 'path',
434+
locale: 'path',
435+
showDefaults: true
436+
}
437+
}
438+
}
439+
440+
const explicitRoutes = configureRoutes(routes, config, {fuzzyPathMatching: false})
441+
const fuzzyRoutes = configureRoutes(routes, config, {fuzzyPathMatching: true})
442+
443+
// Fuzzy matching should produce significantly fewer routes
444+
expect(fuzzyRoutes.length).toBeLessThan(explicitRoutes.length)
445+
// With 2 input routes, fuzzy should produce 4 (2 parameterized + 2 fallback)
446+
expect(fuzzyRoutes).toHaveLength(4)
447+
})
448+
449+
test('Should preserve route properties when using fuzzyPathMatching', () => {
450+
const config = {
451+
app: {
452+
url: {
453+
site: 'path',
454+
locale: 'path',
455+
showDefaults: true
456+
}
457+
}
458+
}
459+
const configuredRoutes = configureRoutes(routes, config, {fuzzyPathMatching: true})
460+
461+
// All routes should have the component and exact properties preserved
462+
configuredRoutes.forEach((route) => {
463+
expect(route.component).toBeDefined()
464+
expect(route.exact).toBe(true)
465+
})
466+
})
467+
})
291468
})

0 commit comments

Comments
 (0)