diff --git a/.changeset/mighty-boats-wink.md b/.changeset/mighty-boats-wink.md new file mode 100644 index 00000000..2ebccec5 --- /dev/null +++ b/.changeset/mighty-boats-wink.md @@ -0,0 +1,5 @@ +--- +"react-resource-router": minor +--- + +Updated the path regex matching method added test to make sure complex routing is not broken diff --git a/docs/router/configuration.md b/docs/router/configuration.md index 1e8fec7e..fe262c47 100644 --- a/docs/router/configuration.md +++ b/docs/router/configuration.md @@ -48,6 +48,8 @@ export const routes = [ ]; ``` +You can also mark a path param as optional by using the `?` suffix. For example, if you want to make the `userId` param optional, you would do so like this `'/user/:userId?'`. + ## History You must provide a `history` instance to the router. Again, this will feel familiar to users of `react-router`. Here is how to do this diff --git a/package-lock.json b/package-lock.json index a223f9ed..ba014aee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "lodash.debounce": "^4.0.8", "lodash.noop": "^3.0.1", - "path-to-regexp": "^1.7.0", + "path-to-regexp": "^6.2.1", "react-sweet-state": "^2.6.4", "url-parse": "^1.5.10" }, @@ -15252,12 +15252,6 @@ "dev": true, "license": "MIT" }, - "node_modules/isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", - "license": "MIT" - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -20067,13 +20061,9 @@ } }, "node_modules/path-to-regexp": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", - "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", - "license": "MIT", - "dependencies": { - "isarray": "0.0.1" - } + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz", + "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==" }, "node_modules/path-type": { "version": "4.0.0", @@ -36757,11 +36747,6 @@ "integrity": "sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw==", "dev": true }, - "isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==" - }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -40282,12 +40267,9 @@ } }, "path-to-regexp": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", - "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", - "requires": { - "isarray": "0.0.1" - } + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz", + "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==" }, "path-type": { "version": "4.0.0", diff --git a/package.json b/package.json index bfe041da..2cfa45d9 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "dependencies": { "lodash.debounce": "^4.0.8", "lodash.noop": "^3.0.1", - "path-to-regexp": "^1.7.0", + "path-to-regexp": "^6.2.1", "react-sweet-state": "^2.6.4", "url-parse": "^1.5.10" }, diff --git a/src/__tests__/integration.test.tsx b/src/__tests__/integration.test.tsx index 97292d30..b32586f7 100644 --- a/src/__tests__/integration.test.tsx +++ b/src/__tests__/integration.test.tsx @@ -5,7 +5,14 @@ import React, { StrictMode } from 'react'; import { defaultRegistry } from 'react-sweet-state'; import { isServerEnvironment } from '../common/utils/is-server-environment'; -import { Route, RouteComponent, Router, type Plugin } from '../index'; +import { + Route, + RouteComponent, + Router, + type Plugin, + usePathParam, + useQueryParam, +} from '../index'; jest.mock('../common/utils/is-server-environment'); @@ -318,5 +325,209 @@ describe(' client-side integration tests', () => { expect(screen.getByText('route component')).toBeInTheDocument(); }); }); + + describe(`path matching integration tests: strict mode ${strictModeState}`, () => { + it('matches dynamic route with optional parameter', () => { + const MigrationComponent = () => { + const [step] = usePathParam('step'); + const [migrationId] = usePathParam('migrationId'); + + return ( +
+ Step: {step}, Migration ID: {migrationId || 'N/A'} +
+ ); + }; + + const route = { + name: 'migration', + path: '/settings/system/migration/:step/:migrationId?', + component: MigrationComponent, + }; + const { history } = mountRouter({ routes: [route], strictMode: true }); + + act(() => { + history.push('/settings/system/migration/plan-configuration/123'); + }); + + expect( + screen.getByText('Step: plan-configuration, Migration ID: 123') + ).toBeInTheDocument(); + }); + + it('matches route with regex constraint on path parameter', () => { + const PlanComponent = () => { + const [planId] = usePathParam('planId'); + + return
Plan ID: {planId}
; + }; + + const route = { + name: 'plans', + path: '/plans/:planId(\\d+)', + component: PlanComponent, + }; + const { history } = mountRouter({ routes: [route], strictMode: true }); + + act(() => { + history.push('/plans/456'); + }); + + expect(screen.getByText('Plan ID: 456')).toBeInTheDocument(); + }); + + it('matches route with multiple dynamic parameters', () => { + const ProjectAppComponent = () => { + const [projectType] = usePathParam('projectType'); + const [projectKey] = usePathParam('projectKey'); + const [appId] = usePathParam('appId'); + + return ( +
+ Project Type: {projectType}, Project Key: {projectKey}, App ID:{' '} + {appId} +
+ ); + }; + + const route = { + name: 'project-app', + path: '/app/:projectType(software|servicedesk)/projects/:projectKey/apps/:appId', + component: ProjectAppComponent, + }; + const { history } = mountRouter({ routes: [route], strictMode: true }); + + act(() => { + history.push('/app/software/projects/PROJ123/apps/456'); + }); + + expect( + screen.getByText( + 'Project Type: software, Project Key: PROJ123, App ID: 456' + ) + ).toBeInTheDocument(); + }); + + it('matches route with dynamic and query parameters', () => { + const IssueComponent = () => { + const [issueKey] = usePathParam('issueKey'); + const [queryParam] = useQueryParam('query'); + + return ( +
+ Issue Key: {issueKey}, Query: {queryParam || 'None'} +
+ ); + }; + + const route = { + name: 'browse', + path: '/browse/:issueKey(\\w+-\\d+)', + component: IssueComponent, + }; + const { history } = mountRouter({ routes: [route], strictMode: true }); + + act(() => { + history.push('/browse/ISSUE-123?query=details'); + }); + + expect( + screen.getByText('Issue Key: ISSUE-123, Query: details') + ).toBeInTheDocument(); + }); + + it('matches route with complex regex constraint on path parameter and wildcard', () => { + const IssueComponent = () => { + const [issueKey] = usePathParam('issueKey'); + + return
Issue Key: {issueKey}
; + }; + + const route = { + name: 'browse', + path: '/browse/:issueKey(\\w+-\\d+)(.*)?', + component: IssueComponent, + }; + const { history } = mountRouter({ routes: [route], strictMode: true }); + + act(() => { + history.push('/browse/ISSUE-123/details'); + }); + + expect(screen.getByText('Issue Key: ISSUE-123')).toBeInTheDocument(); + }); + + it('matches route with multiple dynamic segments and regex constraints', () => { + const SettingsComponent = () => { + const [settingsType] = usePathParam('settingsType'); + const [appId] = usePathParam('appId'); + const [envId] = usePathParam('envId'); + const [route] = usePathParam('route'); + + return ( +
+ Settings Type: {settingsType}, App ID: {appId}, Environment ID:{' '} + {envId}, Route: {route || 'None'} +
+ ); + }; + + const route = { + name: 'settings', + path: '/settings/apps/:settingsType(configure|get-started)/:appId/:envId/:route?', + component: SettingsComponent, + }; + const { history } = mountRouter({ routes: [route], strictMode: true }); + + act(() => { + history.push('/settings/apps/configure/app123/env456/setup'); + }); + + expect( + screen.getByText( + 'Settings Type: configure, App ID: app123, Environment ID: env456, Route: setup' + ) + ).toBeInTheDocument(); + }); + + it('matches route with regex constraint and renders wildcard route for invalid paths', () => { + const IssueComponent = () => { + const [issueKey] = usePathParam('issueKey'); + + return
Issue Key: {issueKey}
; + }; + + const NotFoundComponent = () =>
Not Found
; + + const routes = [ + { + name: 'issue', + path: '/browse/:issueKey(\\w+-\\d+)(.*)?', + component: IssueComponent, + }, + { + name: 'wildcard', + path: '/', + component: NotFoundComponent, + }, + ]; + const { history } = mountRouter({ routes, strictMode: true }); + + act(() => { + history.push('/browse/TEST-1'); + }); + expect(screen.getByText('Issue Key: TEST-1')).toBeInTheDocument(); + + act(() => { + history.push('/browse/1'); + }); + expect(screen.getByText('Not Found')).toBeInTheDocument(); + + act(() => { + history.push('/browse/TEST'); + }); + expect(screen.getByText('Not Found')).toBeInTheDocument(); + }); + }); } }); diff --git a/src/common/utils/match-route/matchPath.ts b/src/common/utils/match-route/matchPath.ts index 8d73d743..0f207978 100644 --- a/src/common/utils/match-route/matchPath.ts +++ b/src/common/utils/match-route/matchPath.ts @@ -1,6 +1,6 @@ // TAKEN FROM https://github.com/ReactTraining/react-router/blob/master/packages/react-router/modules/matchPath.js -import pathToRegexp from 'path-to-regexp'; +import { pathToRegexp } from 'path-to-regexp'; const cache: { [key: string]: any } = {}; const cacheLimit = 10000;