Skip to content

Commit 816bb91

Browse files
authored
chore(sagaRouter): upgrade path-to-regexp (#5480)
1 parent c156674 commit 816bb91

File tree

8 files changed

+262
-71
lines changed

8 files changed

+262
-71
lines changed

.changeset/breezy-ways-care.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
'@talend/react-cmf-router': major
3+
'@talend/react-cmf': major
4+
---
5+
6+
BREAKING CHANGE: Upgraded path-to-regexp from 3.x to 8.x
7+
8+
This upgrade was necessary to resolve security vulnerabilities. The new version introduces two breaking changes that require updates to your application:
9+
10+
1. Optional Path Parameter Syntax Change
11+
- Old syntax: `/resources/:id?`
12+
- New syntax: `/resources{/id}`
13+
14+
This change is required because in path-to-regexp 8.x, the `?` character is reserved for query parameters and will throw a parsing error when used at the end of a path.
15+
16+
2. Root Path Matching Behavior Change
17+
- In v3.x, root path `/` would match any path starting with `/`
18+
- In v8.x, root path `/` only matches exactly `/`
19+
- To match both root and child paths, use the wildcard pattern `/{*path}`
20+
21+
Example migration:
22+
```javascript
23+
// Before
24+
const routes = {
25+
'/': rootSaga,
26+
'/resources/:id?': resourceSaga
27+
};
28+
29+
// After
30+
const routes = {
31+
'/{*path}': rootSaga, // if you want to match all routes
32+
'/resources{/id}': resourceSaga
33+
};
34+
```
35+
For more details about path matching and troubleshooting, see [path-to-regexp documentation](https://github.com/pillarjs/path-to-regexp#errors).

packages/cmf-router/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
"connected-react-router": "^6.9.3",
3030
"history": "^5.3.0",
3131
"lodash": "^4.17.21",
32-
"path-to-regexp": "^3.3.0",
32+
"path-to-regexp": "^8.2.0",
3333
"prop-types": "^15.8.1",
3434
"react-redux": "^7.2.9",
3535
"react-router": "~6.3.0",

packages/cmf-router/src/sagaRouter.md

Lines changed: 98 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,11 @@ yield spawn(routerSaga, history, routes);
4848
```
4949

5050
## Matching pattern
51+
5152
```javascript
5253
const routes = {
53-
"/datasets/add": saga1,
54-
"/connections/:datastoreid/edit/add-dataset": saga2
54+
'/datasets/add': saga1,
55+
'/connections/:datastoreid/edit/add-dataset': saga2,
5556
};
5657
```
5758

@@ -65,9 +66,9 @@ and that we have the following configuration
6566

6667
```javascript
6768
const routes = {
68-
"/datasets": datasets,
69-
"/datasets/add": datasetsSaga,
70-
"/connections/add": connectionsSaga
69+
'/datasets': datasets,
70+
'/datasets/add': datasetsSaga,
71+
'/connections/add': connectionsSaga,
7172
};
7273
```
7374

@@ -90,10 +91,11 @@ and that we have the following configuration
9091

9192
```javascript
9293
const routes = {
93-
"/datasets/add": datasetsSaga,
94-
"/connections/add": connectionsSaga
94+
'/datasets/add': datasetsSaga,
95+
'/connections/add': connectionsSaga,
9596
};
9697
```
98+
9799
only `datasetsSaga` will be executed.
98100

99101
Now the route is changed to `localhost/connections/add`
@@ -116,35 +118,34 @@ so saga of `/datasets` will be restarted when route changes.
116118
Optionally, if you want to run a saga only on exact match, you can pass a configuration `runOnExactMatch` as true,
117119
then saga will be started when its route exactly match current location, and will be stopped when change to any other route.
118120

119-
120121
```javascript
121-
import { sagaRouter } from '@talend/react-cmf';
122122
import { browserHistory as history } from 'react-router';
123123

124+
import { sagaRouter } from '@talend/react-cmf';
125+
124126
const CANCEL_ACTION = 'CANCEL_ACTION';
125127
// route configuration, a url fragment match with a generator
126128
const routes = {
127-
'/datasets': {
128-
// runOnExactMatch: true,
129-
restartOnRouteChange: true,
130-
saga: function* datasets(notUsed, isExact) {
131-
if (!isExact) {
132-
return;
133-
}
134-
yield take(CANCEL_ACTION);
135-
yield put({
136-
type: REDIRECT_CONNECTION_ADD_DATASET_CANCEL,
137-
cmf: {
138-
routerReplace: `/connections/${datastoreId}/edit`,
139-
},
140-
});
141-
},
142-
},
143-
'/datasets/add': function* addDataset() {}
129+
'/datasets': {
130+
// runOnExactMatch: true,
131+
restartOnRouteChange: true,
132+
saga: function* datasets(notUsed, isExact) {
133+
if (!isExact) {
134+
return;
135+
}
136+
yield take(CANCEL_ACTION);
137+
yield put({
138+
type: REDIRECT_CONNECTION_ADD_DATASET_CANCEL,
139+
cmf: {
140+
routerReplace: `/connections/${datastoreId}/edit`,
141+
},
142+
});
143+
},
144+
},
145+
'/datasets/add': function* addDataset() {},
144146
};
145147
```
146148

147-
148149
### Partial route matching
149150

150151
Given the webapp url is `localhost/datasets/add/connection/add`
@@ -153,10 +154,11 @@ and that we have the following configuration
153154

154155
```javascript
155156
const routes = {
156-
"/datasets/add": datasetsSaga,
157-
"/connections/add": connectionsSaga
157+
'/datasets/add': datasetsSaga,
158+
'/connections/add': connectionsSaga,
158159
};
159160
```
161+
160162
only `datasetsSaga` will be executed.
161163

162164
because the route key can be matched on any part of the url.
@@ -171,57 +173,61 @@ and that we have the following configuration
171173

172174
```javascript
173175
const routes = {
174-
"/datasets/add": datasetsSaga,
175-
"/datasets/add/connection/add": datasetConnectionsSaga,
176-
"/connection/add": connectionsSaga,
176+
'/datasets/add': datasetsSaga,
177+
'/datasets/add/connection/add': datasetConnectionsSaga,
178+
'/connection/add': connectionsSaga,
177179
};
178180
```
181+
179182
`datasetsSaga`, `datasetConnectionSaga` and `connectionSaga` are running.
180183

181184
### Route matching and route parameters.
185+
182186
Given the webapp url is `localhost/datasets/50/edit`
183187

184188
and that we have the following configuration
185189

186190
```javascript
187-
function* editDatasetSaga ({datasetId}){
188-
// do something
191+
function* editDatasetSaga({ datasetId }) {
192+
// do something
189193
}
190194

191195
const routes = {
192-
"/datasets/add": datasetsSaga,
193-
"/datasets/:datasetId/edit": editDatasetSaga
196+
'/datasets/add': datasetsSaga,
197+
'/datasets/:datasetId/edit': editDatasetSaga,
194198
};
195199
```
200+
196201
only `editDatasetsSaga` will be executed and :datasetId will be resolved and given to the running saga as a parameter.
197202

198203
url parameters are resolved and given to the executed saga in form of an object, because we can match on many of them.
199204

200205
```javascript
201-
function* connectionSaga ({connectionId, datasetId}){
202-
// do something
206+
function* connectionSaga({ connectionId, datasetId }) {
207+
// do something
203208
}
204209

205210
const routes = {
206-
"/datasets/add": datasetsSaga,
207-
"/datasets/:datasetId/edit": editDatasetSaga,
208-
"/datasets/:datasetId/connections/:connectionId": connectionSaga
211+
'/datasets/add': datasetsSaga,
212+
'/datasets/:datasetId/edit': editDatasetSaga,
213+
'/datasets/:datasetId/connections/:connectionId': connectionSaga,
209214
};
210215
```
211216

212217
### Route matching with route parameter change
218+
213219
Given the webapp url is `localhost/datasets/50/edit`
214220

215221
and that we have the following configuration
216222

217223
```javascript
218-
function* editDatasetSaga ({datasetId}){
219-
// do something
224+
function* editDatasetSaga({ datasetId }) {
225+
// do something
220226
}
221227

222228
const routes = {
223-
"/datasets/add": datasetsSaga,
224-
"/datasets/:datasetId/edit": editDatasetSaga
229+
'/datasets/add': datasetsSaga,
230+
'/datasets/:datasetId/edit': editDatasetSaga,
225231
};
226232
```
227233

@@ -234,24 +240,65 @@ the `editDatasetsSaga` is cancelled, and when its done, restarted with the new v
234240
Only sagas matching on a route which parameter change are restarted.
235241

236242
### Route matching with optionnal parameters
243+
237244
Given the webapp url is `localhost/datasets/add/550`
238245

239246
and that we have the following configuration
240247

241248
```javascript
242-
function* editDatasetSaga ({datasetId}){
243-
// do something
249+
function* editDatasetSaga({ datasetId }) {
250+
// do something
244251
}
245252

246253
const routes = {
247-
"/datasets/add/:connectionId?": datasetsSaga,
248-
"/datasets/:datasetId/edit": editDatasetSaga
254+
'/datasets/add{/:connectionId}': datasetsSaga,
255+
'/datasets/:datasetId/edit': editDatasetSaga,
249256
};
250257
```
258+
251259
datasetSaga will be executed
252260

253261
if the route change to `localhost/datasets/add`
254262

255-
the `datasetsSaga` will be restarted since it still match on `/datasets/add/:connectionId?` route and that the parameter has changed from being a value to being absent.
263+
the `datasetsSaga` will be restarted since it still match on `/datasets/add{/:connectionId}` route and that the parameter has changed from being a value to being absent.
264+
265+
the {/:connectionId} at the end of path means /connectionId is optional.
266+
267+
### Root Path Matching
268+
269+
The root path `/` has special matching behavior that's important to understand:
270+
271+
1. Exact root path matching:
272+
273+
```javascript
274+
const routes = {
275+
'/': function* rootSaga() {
276+
yield take('SOMETHING');
277+
},
278+
};
279+
```
280+
281+
- Only matches exactly `/`
282+
- Does not match child routes like `/tasks` or `/users/123`
283+
- This is because path-to-regexp treats the root path `/` differently than other routes - it won't do partial matching even when `exact` is false
284+
- If you want `/` to match any path that starts with `/`, you need to use a wildcard pattern like `/{*path}`
285+
286+
2. Matching root and all child routes:
287+
288+
```javascript
289+
const routes = {
290+
'/{*path}': function* rootSaga({ path }) {
291+
yield take('SOMETHING');
292+
},
293+
};
294+
```
295+
296+
- Matches both root path `/` and all child routes
297+
- For root path `/`, params will be empty `{}`
298+
- For child routes, `params.path` will contain the remaining path:
299+
- `/tasks``{ path: 'tasks' }`
300+
- `/tasks/123``{ path: 'tasks/123' }`
301+
302+
This pattern is particularly useful when you need a saga to run for all routes in your application while still being able to access the current route path.
256303

257-
the ? at the end of the parameter define that it is optional.
304+
For more details about path matching and troubleshooting, see [path-to-regexp documentation](https://github.com/pillarjs/path-to-regexp#errors).

packages/cmf-router/src/sagaRouter.test.js

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { spawn, take, cancel } from 'redux-saga/effects';
21
import { createMockTask } from '@redux-saga/testing-utils';
2+
import { cancel, spawn, take } from 'redux-saga/effects';
3+
34
import sagaRouter from './sagaRouter';
45

56
describe('sagaRouter import', () => {
@@ -356,4 +357,66 @@ describe('sagaRouter route and route params', () => {
356357
spawn(routes['/matchingroute/:id'], { id: 'anotherId' }, true),
357358
);
358359
});
360+
361+
it('should handle optional route parameters', () => {
362+
const mockTask = createMockTask();
363+
function getMockedHistory() {
364+
let count = 0;
365+
return {
366+
get location() {
367+
if (count === 0) {
368+
count = 1;
369+
return {
370+
pathname: '/matchingroute/tasks/taskId-123',
371+
};
372+
}
373+
return {
374+
pathname: '/matchingroute/tasks',
375+
};
376+
},
377+
};
378+
}
379+
const routes = {
380+
'/matchingroute/:resource{/:optional}': function* matchingSaga() {
381+
yield take('SOMETHING');
382+
},
383+
};
384+
const gen = sagaRouter(getMockedHistory(), routes);
385+
386+
expect(gen.next().value).toEqual(
387+
spawn(
388+
routes['/matchingroute/:resource{/:optional}'],
389+
{ resource: 'tasks', optional: 'taskId-123' },
390+
true,
391+
),
392+
);
393+
expect(gen.next(mockTask).value).toEqual(take('@@router/LOCATION_CHANGE'));
394+
395+
// optional parameter is removed, saga should be restarted
396+
const expectedCancelYield = cancel(mockTask);
397+
expect(gen.next({ type: '@@router/LOCATION_CHANGE' }).value).toEqual(expectedCancelYield);
398+
expect(gen.next().value).toEqual(
399+
spawn(routes['/matchingroute/:resource{/:optional}'], { resource: 'tasks' }, true),
400+
);
401+
});
402+
403+
it('should start root path saga when on child route', () => {
404+
const mockHistory = {
405+
get location() {
406+
return {
407+
pathname: '/tasks',
408+
};
409+
},
410+
};
411+
const routes = {
412+
'/{*path}': function* rootSaga() {
413+
yield take('SOMETHING');
414+
},
415+
};
416+
const gen = sagaRouter(mockHistory, routes);
417+
418+
// Root saga should be started with isExact=true for wildcard path
419+
expect(gen.next().value).toEqual(spawn(routes['/{*path}'], { path: 'tasks' }, true));
420+
expect(gen.next().value).toEqual(take('@@router/LOCATION_CHANGE'));
421+
});
359422
});

0 commit comments

Comments
 (0)