Skip to content

Commit bce0772

Browse files
committed
Enhanced Routing with Nested Path Support and Specificity Prioritization #6923
1 parent 4ff2574 commit bce0772

3 files changed

Lines changed: 91 additions & 65 deletions

File tree

apps/portal/view/ViewportController.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ class ViewportController extends Controller {
5050
'/examples/{itemId}': 'onExamplesRoute',
5151
'/home' : 'onHomeRoute',
5252
'/learn' : 'onLearnRoute',
53-
'/learn/{itemId}' : 'onLearnRoute',
53+
'/learn/{*itemId}' : 'onLearnRoute',
5454
'/services' : 'onServicesRoute'
5555
},
5656
/**

apps/portal/view/learn/MainContainerController.mjs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ class MainContainerController extends Controller {
1717
* @member {Object} routes
1818
*/
1919
routes: {
20-
'/learn' : 'onRouteDefault',
21-
'/learn/{itemId}': 'onRouteLearnItem'
20+
'/learn' : 'onRouteDefault',
21+
'/learn/{*itemId}': 'onRouteLearnItem'
2222
}
2323
}
2424

@@ -128,13 +128,14 @@ class MainContainerController extends Controller {
128128
*/
129129
onRouteLearnItem(data) {
130130
let stateProvider = this.getStateProvider(),
131-
store = stateProvider.getStore('contentTree');
131+
store = stateProvider.getStore('contentTree'),
132+
itemId = data.itemId;
132133

133134
if (store.getCount() > 0) {
134-
stateProvider.data.currentPageRecord = store.get(data.itemId)
135+
stateProvider.data.currentPageRecord = store.get(itemId)
135136
} else {
136137
store.on({
137-
load : () => {stateProvider.data.currentPageRecord = store.get(data.itemId)},
138+
load : () => {stateProvider.data.currentPageRecord = store.get(itemId)},
138139
delay: 10,
139140
once : true
140141
})

src/controller/Base.mjs

Lines changed: 84 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ import Base from '../core/Base.mjs';
22
import HashHistory from '../util/HashHistory.mjs';
33

44
const
5-
amountSlashesRegex = /\//g,
6-
routeParamRegex = /{[^\s/]+}/g
5+
amountSlashesRegex = /\//g,
6+
// Regex to match route parameters like {paramName}, {*paramName}, or {...paramName}
7+
routeParamRegex = /{(\*|\.\.\.)?([^}]+)}/g,
8+
// Regex to extract the parameter name from a single route segment (e.g., {*itemId} -> itemId)
9+
paramNameExtractionRegex = /{(\*|\.\.\.)?([^}]+)}/;
710

811
/**
912
* @class Neo.controller.Base
@@ -22,25 +25,33 @@ class Controller extends Base {
2225
*/
2326
ntype: 'controller',
2427
/**
25-
* If the URL does not contain a hash value when creating this controller instance,
26-
* neo will set this hash value for us.
28+
* If the URL does not contain a hash value when this controller instance is created,
29+
* Neo.mjs will automatically set this hash value, ensuring a default route is active.
2730
* @member {String|null} defaultHash=null
2831
*/
2932
defaultHash: null,
3033
/**
34+
* Specifies the handler method to be invoked when no other defined route matches the URL hash.
35+
* This acts as a fallback for unhandled routes.
3136
* @member {String|null} defaultRoute=null
3237
*/
3338
defaultRoute: null,
3439
/**
40+
* Internal map of compiled regular expressions for each route, used for efficient hash matching.
41+
* @protected
3542
* @member {Object} handleRoutes={}
3643
*/
3744
handleRoutes: {},
3845
/**
46+
* Defines the routing rules for the controller. Keys are route patterns, and values are either
47+
* handler method names (String) or objects containing `handler` and optional `preHandler` method names.
48+
* Route patterns can include parameters like `{paramName}` and wildcards like `{*paramName}` for nested paths.
3949
* @example
4050
* routes: {
4151
* '/home' : 'handleHomeRoute',
4252
* '/users/{userId}' : {handler: 'handleUserRoute', preHandler: 'preHandleUserRoute'},
4353
* '/users/{userId}/posts/{postId}': 'handlePostRoute',
54+
* '/learn/{*itemId}' : 'onLearnRoute', // Captures nested paths like /learn/gettingstarted/Workspaces
4455
* 'default' : 'handleOtherRoutes'
4556
* }
4657
* @member {Object} routes_={}
@@ -49,6 +60,8 @@ class Controller extends Base {
4960
}
5061

5162
/**
63+
* Creates a new Controller instance and registers its `onHashChange` method
64+
* to listen for changes in the browser's URL hash.
5265
* @param {Object} config
5366
*/
5467
construct(config) {
@@ -58,7 +71,8 @@ class Controller extends Base {
5871
}
5972

6073
/**
61-
* Triggered after the routes config got changed
74+
* Processes the defined routes configuration, compiling route patterns into regular expressions
75+
* for efficient matching and sorting them by specificity (more slashes first).
6276
* @param {Object} value
6377
* @param {Object} oldValue
6478
* @protected
@@ -78,7 +92,13 @@ class Controller extends Base {
7892
if (key.toLowerCase() === 'default'){
7993
me.defaultRoute = value[key]
8094
} else {
81-
me.handleRoutes[key] = new RegExp(key.replace(routeParamRegex, '([\\w-.]+)')+'$')
95+
me.handleRoutes[key] = new RegExp(key.replace(routeParamRegex, (match, isWildcard, paramName) => {
96+
if (isWildcard || paramName.startsWith('*')) {
97+
return '(.*)';
98+
} else {
99+
return '([\\w-.]+)';
100+
}
101+
}))
82102
}
83103
})
84104
}
@@ -93,7 +113,8 @@ class Controller extends Base {
93113
}
94114

95115
/**
96-
*
116+
* Lifecycle method called after the controller has been constructed.
117+
* It handles the initial routing based on the current URL hash or `defaultHash`.
97118
*/
98119
async onConstructed() {
99120
let me = this,
@@ -118,9 +139,10 @@ class Controller extends Base {
118139
}
119140

120141
/**
121-
* Placeholder method which gets triggered when the hash inside the browser url changes
122-
* @param {Object} value
123-
* @param {Object} oldValue
142+
* Handles changes in the browser's URL hash. It identifies the most specific matching route
143+
* and dispatches the corresponding handler, optionally executing a preHandler first.
144+
* @param {Object} value - The new hash history entry.
145+
* @param {Object} oldValue - The previous hash history entry.
124146
*/
125147
async onHashChange(value, oldValue) {
126148
// We only want to trigger hash changes for the same browser window (SharedWorker context)
@@ -129,85 +151,88 @@ class Controller extends Base {
129151
}
130152

131153
let me = this,
132-
counter = 0,
133-
hasRouteBeenFound = false,
134154
{handleRoutes, routes} = me,
135155
routeKeys = Object.keys(handleRoutes),
136-
routeKeysLength = routeKeys.length,
137-
arrayParamIds, arrayParamValues, handler, key, paramObject, preHandler, responsePreHandler, result, route;
156+
bestMatch = null,
157+
bestMatchKey = null,
158+
bestMatchParams = null;
138159

139-
while (routeKeysLength > 0 && counter < routeKeysLength && !hasRouteBeenFound) {
140-
key = routeKeys[counter];
141-
handler = null;
142-
preHandler = null;
143-
responsePreHandler = null;
144-
paramObject = {};
145-
result = value.hashString.match(handleRoutes[key]);
160+
for (let i = 0; i < routeKeys.length; i++) {
161+
const key = routeKeys[i];
162+
const result = value.hashString.match(handleRoutes[key]);
146163

147164
if (result) {
148-
arrayParamIds = key.match(routeParamRegex);
149-
arrayParamValues = result.splice(1, result.length - 1);
150-
151-
if (arrayParamIds && arrayParamIds.length !== arrayParamValues.length) {
152-
throw 'Number of IDs and number of Values do not match'
165+
const arrayParamIds = key.match(routeParamRegex);
166+
const arrayParamValues = result.splice(1, result.length - 1);
167+
const paramObject = {};
168+
169+
if (arrayParamIds) {
170+
for (let j = 0; j < arrayParamIds.length; j++) {
171+
const paramMatch = arrayParamIds[j].match(paramNameExtractionRegex);
172+
if (paramMatch) {
173+
const paramName = paramMatch[2];
174+
paramObject[paramName] = arrayParamValues[j];
175+
}
176+
}
153177
}
154178

155-
for (let i = 0; arrayParamIds && i < arrayParamIds.length; i++) {
156-
paramObject[arrayParamIds[i].substring(1, arrayParamIds[i].length - 1)] = arrayParamValues[i]
179+
// Logic to determine the best matching route:
180+
// 1. Prioritize routes that match a longer string (more specific match).
181+
// 2. If lengths are equal, prioritize routes with more slashes (deeper nesting).
182+
if (!bestMatch || (result[0].length > bestMatch[0].length) ||
183+
(result[0].length === bestMatch[0].length && (key.match(amountSlashesRegex) || []).length > (bestMatchKey.match(amountSlashesRegex) || []).length)) {
184+
bestMatch = result;
185+
bestMatchKey = key;
186+
bestMatchParams = paramObject;
157187
}
188+
}
189+
}
158190

159-
route = routes[key];
160-
161-
if (Neo.isString(route)) {
162-
handler = route;
163-
responsePreHandler = true
164-
} else if (Neo.isObject(route)) {
165-
handler = route.handler;
166-
preHandler = route.preHandler
167-
}
191+
if (bestMatch) {
192+
const route = routes[bestMatchKey];
193+
let handler = null;
194+
let preHandler = null;
168195

169-
hasRouteBeenFound = true
196+
if (Neo.isString(route)) {
197+
handler = route;
198+
} else if (Neo.isObject(route)) {
199+
handler = route.handler;
200+
preHandler = route.preHandler;
170201
}
171202

172-
counter++
173-
}
174-
175-
// execute
176-
if (hasRouteBeenFound) {
203+
let responsePreHandler = true;
177204
if (preHandler) {
178-
responsePreHandler = await me[preHandler]?.call(me, paramObject, value, oldValue)
179-
} else {
180-
responsePreHandler = true
205+
responsePreHandler = await me[preHandler]?.call(me, bestMatchParams, value, oldValue);
181206
}
182207

183208
if (responsePreHandler) {
184-
await me[handler]?.call(me, paramObject, value, oldValue)
209+
await me[handler]?.call(me, bestMatchParams, value, oldValue);
185210
}
186-
}
187-
188-
if (routeKeys.length > 0 && !hasRouteBeenFound) {
211+
} else {
189212
if (me.defaultRoute) {
190-
me[me.defaultRoute]?.(value, oldValue)
213+
me[me.defaultRoute]?.(value, oldValue);
191214
} else {
192-
me.onNoRouteFound(value, oldValue)
215+
me.onNoRouteFound(value, oldValue);
193216
}
194217
}
195218
}
196219

197220
/**
198-
* Placeholder method which gets triggered when an invalid route is called
199-
* @param {Object} value
200-
* @param {Object} oldValue
221+
* Placeholder method invoked when no matching route is found for the current URL hash.
222+
* Controllers can override this to implement custom behavior for unhandled routes.
223+
* @param {Object} value - The current hash history entry.
224+
* @param {Object} oldValue - The previous hash history entry.
201225
*/
202226
onNoRouteFound(value, oldValue) {
203227

204228
}
205229

206230
/**
207-
* Internal helper method to sort routes by their amount of slashes
208-
* @param {String} route1
209-
* @param {String} route2
210-
* @returns {Number}
231+
* Internal helper method to sort routes by their specificity.
232+
* Routes with more slashes are considered more specific and are prioritized.
233+
* @param {String} route1 - The first route string to compare.
234+
* @param {String} route2 - The second route string to compare.
235+
* @returns {Number} A negative value if route1 is more specific, a positive value if route2 is more specific, or 0 if they have equal specificity.
211236
*/
212237
#sortRoutes(route1, route2) {
213238
return (route1.match(amountSlashesRegex) || []).length - (route2.match(amountSlashesRegex)|| []).length

0 commit comments

Comments
 (0)