diff --git a/core/src/components.d.ts b/core/src/components.d.ts index cf24c5fcd4f..afddade5171 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -2719,14 +2719,14 @@ export namespace Components { */ "componentProps"?: { [key: string]: any }; /** - * Relative path that needs to match in order for this route to apply. Accepts paths similar to expressjs so that you can define parameters in the url /foo/:bar where bar would be available in incoming props. + * Relative path that needs to match in order for this route to apply. Accepts paths similar to expressjs so that you can define parameters in the url /foo/:bar where bar would be available in incoming props. A url ending in `*`, such as `*` or `/admin/*`, matches any remaining path, including none, so `/admin/*` also matches `/admin`. It only wins over a route that matches the whole path when it has more fixed segments, so `/admin/*` wins over `/:section/:id` but not over `/admin/:id`. It always wins over a route that matches by ignoring trailing segments, such as `/admin` for `/admin/users`. The matched path is passed in the `pathMatch` prop without a leading `/`. A `*` on a route with child routes is ignored. * @default '' */ "url": string; } interface IonRouteRedirect { /** - * A redirect route, redirects "from" a URL "to" another URL. This property is that "from" URL. It needs to be an exact match of the navigated URL in order to apply. The path specified in this value is always an absolute path, even if the initial `/` slash is not specified. + * A redirect route, redirects "from" a URL "to" another URL. This property is that "from" URL. It needs to be an exact match of the navigated URL in order to apply, unless it contains a `*`. A `*` matches the rest of the path from that segment on, as long as the path has at least as many segments as `from`, so `/admin/*` matches `/admin/posts` but not `/admin`. The path specified in this value is always an absolute path, even if the initial `/` slash is not specified. */ "from": string; /** @@ -7896,14 +7896,14 @@ declare namespace LocalJSX { */ "onIonRouteDataChanged"?: (event: IonRouteCustomEvent) => void; /** - * Relative path that needs to match in order for this route to apply. Accepts paths similar to expressjs so that you can define parameters in the url /foo/:bar where bar would be available in incoming props. + * Relative path that needs to match in order for this route to apply. Accepts paths similar to expressjs so that you can define parameters in the url /foo/:bar where bar would be available in incoming props. A url ending in `*`, such as `*` or `/admin/*`, matches any remaining path, including none, so `/admin/*` also matches `/admin`. It only wins over a route that matches the whole path when it has more fixed segments, so `/admin/*` wins over `/:section/:id` but not over `/admin/:id`. It always wins over a route that matches by ignoring trailing segments, such as `/admin` for `/admin/users`. The matched path is passed in the `pathMatch` prop without a leading `/`. A `*` on a route with child routes is ignored. * @default '' */ "url"?: string; } interface IonRouteRedirect { /** - * A redirect route, redirects "from" a URL "to" another URL. This property is that "from" URL. It needs to be an exact match of the navigated URL in order to apply. The path specified in this value is always an absolute path, even if the initial `/` slash is not specified. + * A redirect route, redirects "from" a URL "to" another URL. This property is that "from" URL. It needs to be an exact match of the navigated URL in order to apply, unless it contains a `*`. A `*` matches the rest of the path from that segment on, as long as the path has at least as many segments as `from`, so `/admin/*` matches `/admin/posts` but not `/admin`. The path specified in this value is always an absolute path, even if the initial `/` slash is not specified. */ "from": string; /** diff --git a/core/src/components/route-redirect/route-redirect.tsx b/core/src/components/route-redirect/route-redirect.tsx index f7a12bcca43..5d0b0ffbc35 100644 --- a/core/src/components/route-redirect/route-redirect.tsx +++ b/core/src/components/route-redirect/route-redirect.tsx @@ -7,7 +7,9 @@ import { Component, Event, Prop, Watch } from '@stencil/core'; export class RouteRedirect implements ComponentInterface { /** * A redirect route, redirects "from" a URL "to" another URL. This property is that "from" URL. - * It needs to be an exact match of the navigated URL in order to apply. + * It needs to be an exact match of the navigated URL in order to apply, unless it contains a `*`. + * A `*` matches the rest of the path from that segment on, as long as the path has at least as + * many segments as `from`, so `/admin/*` matches `/admin/posts` but not `/admin`. * * The path specified in this value is always an absolute path, even if the initial `/` slash * is not specified. diff --git a/core/src/components/route/route.tsx b/core/src/components/route/route.tsx index 5010cac4899..16ba2f49a01 100644 --- a/core/src/components/route/route.tsx +++ b/core/src/components/route/route.tsx @@ -14,6 +14,14 @@ export class Route implements ComponentInterface { * * Accepts paths similar to expressjs so that you can define parameters * in the url /foo/:bar where bar would be available in incoming props. + * + * A url ending in `*`, such as `*` or `/admin/*`, matches any remaining path, + * including none, so `/admin/*` also matches `/admin`. It only wins over a + * route that matches the whole path when it has more fixed segments, so + * `/admin/*` wins over `/:section/:id` but not over `/admin/:id`. It always + * wins over a route that matches by ignoring trailing segments, such as + * `/admin` for `/admin/users`. The matched path is passed in the `pathMatch` + * prop without a leading `/`. A `*` on a route with child routes is ignored. */ @Prop() url = ''; diff --git a/core/src/components/router/test/catch-all/index.html b/core/src/components/router/test/catch-all/index.html new file mode 100644 index 00000000000..c5cb18e608d --- /dev/null +++ b/core/src/components/router/test/catch-all/index.html @@ -0,0 +1,83 @@ + + + + + Router - Catch-all + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/src/components/router/test/catch-all/router.e2e.ts b/core/src/components/router/test/catch-all/router.e2e.ts new file mode 100644 index 00000000000..36d353ccf14 --- /dev/null +++ b/core/src/components/router/test/catch-all/router.e2e.ts @@ -0,0 +1,66 @@ +import { expect } from '@playwright/test'; +import { configs, test } from '@utils/test/playwright'; + +/** + * This behavior does not vary across modes/directions. + */ +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('router: catch-all'), () => { + test('should render the matching route over the catch-all', async ({ page }) => { + await page.goto(`/src/components/router/test/catch-all#/docs`, config); + + await expect(page.locator('docs-index')).toBeVisible(); + await expect(page.locator('docs-not-found')).toHaveCount(0); + await expect(page.locator('not-found-page')).toHaveCount(0); + }); + + test('should render the catch-all for an unknown path', async ({ page }) => { + await page.goto(`/src/components/router/test/catch-all#/unknown/deep/path`, config); + + const notFound = page.locator('not-found-page'); + await expect(notFound).toBeVisible(); + await expect(notFound.locator('.path-match')).toHaveText('unknown/deep/path'); + }); + + test('should prefer the catch-all with the most fixed segments', async ({ page }) => { + await page.goto(`/src/components/router/test/catch-all#/docs/missing/page`, config); + + const notFound = page.locator('docs-not-found'); + await expect(notFound).toBeVisible(); + await expect(notFound.locator('.path-match')).toHaveText('missing/page'); + await expect(page.locator('not-found-page')).toHaveCount(0); + }); + + test('should render the catch-all over a route that ignores trailing segments', async ({ page }) => { + await page.goto(`/src/components/router/test/catch-all#/docs/intro/extra`, config); + + const notFound = page.locator('docs-not-found'); + await expect(notFound).toBeVisible(); + await expect(notFound.locator('.path-match')).toHaveText('intro/extra'); + await expect(page.locator('docs-intro')).toHaveCount(0); + }); + + test('should update the matched path when navigating between unknown paths', async ({ page }) => { + await page.goto(`/src/components/router/test/catch-all#/unknown/one`, config); + await expect(page.locator('not-found-page .path-match')).toHaveText('unknown/one'); + + await page.evaluate(() => document.querySelector('ion-router')!.push('/another/two')); + + await expect(page.locator('not-found-page .path-match')).toHaveText('another/two'); + await expect(page).toHaveURL(/#\/another\/two$/); + }); + + test('should restore the unknown path when switching back to the tab', async ({ page }) => { + await page.goto(`/src/components/router/test/catch-all#/docs/missing/page`, config); + await expect(page.locator('docs-not-found')).toBeVisible(); + + await page.click('ion-tab-button[tab="tab-home"]'); + await expect(page.locator('tab-home')).toBeVisible(); + await expect(page).toHaveURL(/#\/$/); + + await page.click('ion-tab-button[tab="tab-docs"]'); + await expect(page.locator('docs-not-found')).toBeVisible(); + await expect(page).toHaveURL(/#\/docs\/missing\/page$/); + }); + }); +}); diff --git a/core/src/components/router/test/matching.spec.tsx b/core/src/components/router/test/matching.spec.tsx index 9b8947c38a7..beec6c2f331 100644 --- a/core/src/components/router/test/matching.spec.tsx +++ b/core/src/components/router/test/matching.spec.tsx @@ -6,6 +6,7 @@ import { matchesRedirect, mergeParams, findChainForSegments, + findChainForIDs, } from '../utils/matching'; import { parsePath } from '../utils/path'; @@ -200,6 +201,278 @@ describe('findChainForSegments', () => { }); }); +describe('wildcard routes', () => { + const NOT_FOUND: RouteChain = [{ id: 'not-found', segments: ['*'], params: undefined }]; + + it('should match any depth and expose the matched path', () => { + expect(matchesSegments(['a'], NOT_FOUND)).toEqual([ + { id: 'not-found', segments: ['*'], params: { pathMatch: 'a' } }, + ]); + expect(matchesSegments(['a', 'b', 'c'], NOT_FOUND)).toEqual([ + { id: 'not-found', segments: ['*'], params: { pathMatch: 'a/b/c' } }, + ]); + }); + + it('should match the root path', () => { + expect(matchesSegments([''], NOT_FOUND)).toEqual([{ id: 'not-found', segments: ['*'], params: { pathMatch: '' } }]); + }); + + it('should match after a fixed prefix', () => { + const chain: RouteChain = [{ id: 'admin', segments: ['admin', '*'], params: { role: 'owner' } }]; + expect(matchesSegments(['admin'], chain)).toEqual([ + { id: 'admin', segments: ['admin', '*'], params: { role: 'owner', pathMatch: '' } }, + ]); + expect(matchesSegments(['admin', 'users', '1'], chain)).toEqual([ + { id: 'admin', segments: ['admin', '*'], params: { role: 'owner', pathMatch: 'users/1' } }, + ]); + expect(matchesSegments(['users'], chain)).toEqual(null); + }); + + it('should match a wildcard nested under a default route', () => { + const chain: RouteChain = [ + { id: 'tabs', segments: [''], params: undefined }, + { id: 'not-found', segments: ['*'], params: undefined }, + ]; + expect(matchesSegments(['a', 'b'], chain)).toEqual([ + { id: 'tabs', segments: [''], params: undefined }, + { id: 'not-found', segments: ['*'], params: { pathMatch: 'a/b' } }, + ]); + }); + + it('should let the children of a parent wildcard match the rest of the path', () => { + const chain: RouteChain = [ + { id: 'admin', segments: ['admin', '*'], params: undefined }, + { id: 'users', segments: ['users'], params: undefined }, + ]; + expect(matchesSegments(['admin', 'users'], chain)).toEqual(chain); + expect(matchesSegments(['admin', 'posts'], chain)).toEqual(null); + }); + + it('should match a wildcard nested under a parent wildcard', () => { + const chain: RouteChain = [ + { id: 'parent', segments: ['*'], params: undefined }, + { id: 'not-found', segments: ['*'], params: undefined }, + ]; + expect(findChainForSegments(['x', 'y'], [chain])).toEqual([ + { id: 'parent', segments: ['*'], params: undefined }, + { id: 'not-found', segments: ['*'], params: { pathMatch: 'x/y' } }, + ]); + }); + + it('should match a mid-path * literally', () => { + const chain: RouteChain = [{ id: '1', segments: ['a', '*', 'b'], params: undefined }]; + expect(matchesSegments(['a', '*', 'b'], chain)).toEqual(chain); + expect(matchesSegments(['a', 'x', 'b'], chain)).toEqual(null); + }); + + it('should lose to default, fixed and parameter routes on a single segment path', () => { + const home: RouteChain = [{ id: 'home', segments: [''], params: undefined }]; + const foo: RouteChain = [{ id: 'foo', segments: ['foo'], params: undefined }]; + const param: RouteChain = [{ id: 'param', segments: [':id'], params: undefined }]; + const routes = [NOT_FOUND, home, foo]; + + expect(findChainForSegments([''], routes)![0].id).toEqual('home'); + expect(findChainForSegments(['foo'], routes)![0].id).toEqual('foo'); + expect(findChainForSegments(['bar'], routes)![0].id).toEqual('not-found'); + expect(findChainForSegments(['bar'], [NOT_FOUND, param])![0].id).toEqual('param'); + }); + + it('should win over a route that ignored trailing segments', () => { + const admin: RouteChain = [{ id: 'admin', segments: ['admin'], params: undefined }]; + const adminNotFound: RouteChain = [{ id: 'admin-not-found', segments: ['admin', '*'], params: undefined }]; + + expect(findChainForSegments(['admin'], [admin, adminNotFound, NOT_FOUND])![0].id).toEqual('admin'); + expect(findChainForSegments(['admin', 'users', '1'], [admin, adminNotFound, NOT_FOUND])).toEqual([ + { id: 'admin-not-found', segments: ['admin', '*'], params: { pathMatch: 'users/1' } }, + ]); + expect(findChainForSegments(['admin', 'users'], [admin, NOT_FOUND])![0].id).toEqual('not-found'); + }); + + it('should lose to a route that matched the whole path', () => { + const profile: RouteChain = [{ id: 'profile', segments: ['settings', 'profile'], params: undefined }]; + const item: RouteChain = [{ id: 'item', segments: ['settings', ':section', ':item'], params: undefined }]; + + expect(findChainForSegments(['settings', 'profile', 'avatar'], [profile, item, NOT_FOUND])).toEqual([ + { id: 'item', segments: ['settings', ':section', ':item'], params: { section: 'profile', item: 'avatar' } }, + ]); + // Without the wildcard, the route that ignored a trailing segment still wins + expect(findChainForSegments(['settings', 'profile', 'avatar'], [profile, item])![0].id).toEqual('profile'); + }); + + it('should not change how routes without a wildcard match trailing segments', () => { + const admin: RouteChain = [{ id: 'admin', segments: ['admin'], params: undefined }]; + expect(findChainForSegments(['admin', 'users'], [admin])![0].id).toEqual('admin'); + }); + + it('should win over parameter routes with fewer fixed segments and lose to ones with as many', () => { + const admin: RouteChain = [{ id: 'admin', segments: ['admin', '*'], params: undefined }]; + const section: RouteChain = [{ id: 'section', segments: [':section', ':id'], params: undefined }]; + const adminId: RouteChain = [{ id: 'admin-id', segments: ['admin', ':id'], params: undefined }]; + + expect(findChainForSegments(['admin', 'users'], [section, admin])![0].id).toEqual('admin'); + expect(findChainForSegments(['admin', 'users'], [admin, adminId])![0].id).toEqual('admin-id'); + expect(findChainForSegments(['admin', 'users'], [adminId, admin])![0].id).toEqual('admin-id'); + }); + + it('should lose to a parameter route with the same fixed segments when nested under default routes', () => { + const docsNotFound: RouteChain = [ + { id: 'tabs', segments: [''], params: undefined }, + { id: 'tab-docs', segments: ['docs'], params: undefined }, + { id: 'docs-not-found', segments: ['*'], params: undefined }, + ]; + const docDetail: RouteChain = [{ id: 'doc-detail', segments: ['docs', ':id'], params: undefined }]; + + expect(findChainForSegments(['docs', 'abc'], [docsNotFound, docDetail])![0].id).toEqual('doc-detail'); + expect(findChainForSegments(['docs', 'abc', 'def'], [docsNotFound, docDetail])![2].id).toEqual('docs-not-found'); + }); + + it('should rank routes without a wildcard by priority', () => { + const user: RouteChain = [{ id: 'user', segments: ['users', ':id'], params: undefined }]; + const me: RouteChain = [{ id: 'me', segments: ['users', 'me'], params: undefined }]; + + expect(findChainForSegments(['users', 'me'], [user, me, NOT_FOUND])![0].id).toEqual('me'); + expect(findChainForSegments(['users', 'me'], [me, user, NOT_FOUND])![0].id).toEqual('me'); + }); + + it('should rank wildcard routes with as many fixed segments by priority, ignoring the wildcard', () => { + const deep: RouteChain = [{ id: 'deep', segments: [':a', ':b', '*'], params: undefined }]; + const nested: RouteChain = [ + { id: 'tabs', segments: [''], params: undefined }, + { id: 'tab', segments: [''], params: undefined }, + { id: 'shallow', segments: [':a', '*'], params: undefined }, + ]; + + expect(findChainForSegments(['x', 'y'], [nested, deep])![0].id).toEqual('deep'); + expect(findChainForSegments(['x', 'y'], [deep, nested])![0].id).toEqual('deep'); + }); + + it('should fall back to the best full match in any declaration order when one has as many fixed segments', () => { + const admin: RouteChain = [{ id: 'admin', segments: ['admin', 'users', '*'], params: undefined }]; + const user: RouteChain = [{ id: 'user', segments: ['admin', 'users', ':id'], params: undefined }]; + const settings: RouteChain = [{ id: 'settings', segments: [':org', ':team', 'settings'], params: undefined }]; + const path = ['admin', 'users', 'settings']; + + expect(findChainForSegments(path, [admin, user, settings])![0].id).toEqual('settings'); + expect(findChainForSegments(path, [settings, user, admin])![0].id).toEqual('settings'); + expect(findChainForSegments(path, [admin, settings, user])![0].id).toEqual('settings'); + }); + + it('should prefer the wildcard with more fixed segments over one with a higher priority', () => { + const nested: RouteChain = [ + { id: 'tabs', segments: [''], params: undefined }, + { id: 'tab', segments: [''], params: undefined }, + { id: 'docs', segments: ['docs'], params: undefined }, + { id: 'docs-not-found', segments: ['*'], params: undefined }, + ]; + const guide: RouteChain = [{ id: 'guide-not-found', segments: ['docs', 'guide', '*'], params: undefined }]; + const sub: RouteChain = [{ id: 'sub', segments: ['docs', ':id', ':sub'], params: undefined }]; + + expect(findChainForSegments(['docs', 'guide', 'x'], [nested, guide, sub])![0].id).toEqual('guide-not-found'); + expect(findChainForSegments(['docs', 'guide', 'x'], [sub, guide, nested])![0].id).toEqual('guide-not-found'); + }); + + it('should prefer the wildcard with the most fixed segments', () => { + const admin: RouteChain = [{ id: 'admin', segments: ['admin', '*'], params: undefined }]; + expect(findChainForSegments(['admin', 'x'], [NOT_FOUND, admin])![0].id).toEqual('admin'); + expect(findChainForSegments(['other', 'x'], [NOT_FOUND, admin])![0].id).toEqual('not-found'); + }); +}); + +describe('findChainForIDs', () => { + it('should prefer a route over a wildcard route on a tie regardless of declaration order', () => { + const notFound: RouteChain = [ + { id: 'tabs', segments: [''], params: undefined }, + { id: 'docs-not-found', segments: ['*'], params: undefined }, + ]; + const index: RouteChain = [ + { id: 'tabs', segments: [''], params: undefined }, + { id: 'docs-index', segments: [''], params: undefined }, + ]; + const ids = [{ id: 'tabs', element: undefined }]; + + expect(findChainForIDs(ids, [notFound, index])![1].id).toEqual('docs-index'); + expect(findChainForIDs(ids, [index, notFound])![1].id).toEqual('docs-index'); + }); + + it('should keep a wildcard route on a tie when the other route is missing a parameter', () => { + const notFound: RouteChain = [ + { id: 'tabs', segments: [''], params: undefined }, + { id: 'docs-not-found', segments: ['*'], params: undefined }, + ]; + const slug: RouteChain = [ + { id: 'tabs', segments: [''], params: undefined }, + { id: 'doc-page', segments: [':slug'], params: undefined }, + ]; + const ids = [{ id: 'tabs', element: undefined }]; + + expect(findChainForIDs(ids, [notFound, slug])![1].id).toEqual('docs-not-found'); + expect(findChainForIDs(ids, [slug, notFound])![1].id).toEqual('docs-not-found'); + }); + + it('should prefer a route over a wildcard route on a tie when the outlet reports its parameter', () => { + const notFound: RouteChain = [ + { id: 'docs', segments: ['docs'], params: undefined }, + { id: 'doc-page', segments: ['*'], params: undefined }, + ]; + const view: RouteChain = [ + { id: 'docs', segments: ['docs'], params: undefined }, + { id: 'doc-page', segments: ['view', ':slug'], params: undefined }, + ]; + const ids = [ + { id: 'docs', element: undefined }, + { id: 'doc-page', element: undefined, params: { slug: 'a' } }, + ]; + + expect(findChainForIDs(ids, [notFound, view])).toEqual([ + { id: 'docs', segments: ['docs'], params: undefined }, + { id: 'doc-page', segments: ['view', ':slug'], params: { slug: 'a' } }, + ]); + }); + + it('should prefer a route that can build a url, then a wildcard route, in any declaration order', () => { + const tabs = { id: 'tabs', segments: [''], params: undefined }; + const slug: RouteChain = [tabs, { id: 'doc-page', segments: [':slug'], params: undefined }]; + const index: RouteChain = [tabs, { id: 'docs-index', segments: [''], params: undefined }]; + const notFound: RouteChain = [tabs, { id: 'docs-not-found', segments: ['*'], params: undefined }]; + const ids = [{ id: 'tabs', element: undefined }]; + + expect(findChainForIDs(ids, [slug, index, notFound])![1].id).toEqual('docs-index'); + expect(findChainForIDs(ids, [slug, notFound, index])![1].id).toEqual('docs-index'); + expect(findChainForIDs(ids, [notFound, slug, index])![1].id).toEqual('docs-index'); + }); + + it('should keep the first route declared on a tie without a wildcard route', () => { + const tabs = { id: 'tabs', segments: [''], params: undefined }; + const slug: RouteChain = [tabs, { id: 'doc-page', segments: [':slug'], params: undefined }]; + const index: RouteChain = [tabs, { id: 'docs-index', segments: [''], params: undefined }]; + const ids = [{ id: 'tabs', element: undefined }]; + + expect(findChainForIDs(ids, [slug, index])![1].id).toEqual('doc-page'); + }); + + it('should prefer a wildcard route when the outlet reports the matched path', () => { + const index: RouteChain = [ + { id: 'docs', segments: ['docs'], params: undefined }, + { id: 'doc-page', segments: [''], params: undefined }, + ]; + const slug: RouteChain = [ + { id: 'docs', segments: ['docs'], params: undefined }, + { id: 'doc-page', segments: [':slug'], params: undefined }, + ]; + const notFound: RouteChain = [ + { id: 'docs', segments: ['docs'], params: undefined }, + { id: 'doc-page', segments: ['*'], params: undefined }, + ]; + const ids = [ + { id: 'docs', element: undefined }, + { id: 'doc-page', element: undefined, params: { pathMatch: 'a/b' } }, + ]; + + expect(findChainForIDs(ids, [index, notFound])![1].segments).toEqual(['*']); + expect(findChainForIDs(ids, [slug, notFound])![1].segments).toEqual(['*']); + }); +}); + describe('mergeParams', () => { it('should merge undefined', () => { expect(mergeParams(undefined, undefined)).toBeUndefined(); @@ -249,6 +522,13 @@ describe('RouterSegments', () => { expect(s.next()).toEqual(''); expect(s.next()).toEqual(''); }); + + it('should return the non empty remaining segments', () => { + const s = new RouterSegments(['a', '', 'b', '', 'c']); + expect(s.next()).toEqual('a'); + expect(s.rest()).toEqual(['b', 'c']); + expect(s.next()).toEqual(''); + }); }); describe('matchesRedirect', () => { diff --git a/core/src/components/router/test/path.spec.tsx b/core/src/components/router/test/path.spec.tsx index e8e398d9389..f6eb351f5cd 100644 --- a/core/src/components/router/test/path.spec.tsx +++ b/core/src/components/router/test/path.spec.tsx @@ -121,6 +121,23 @@ describe('chainToSegments', () => { expect(chainToSegments(chain)).toEqual(['segment', 'to', 'hola', 'hey', 'name', 'adios', 'manu', '123']); }); + it('should replace a wildcard with the path it matched', () => { + const chain: RouteChain = [ + { id: '1', segments: [''], params: undefined }, + { id: '2', segments: ['admin', '*'], params: { pathMatch: 'users/1' } }, + ]; + expect(chainToSegments(chain)).toEqual(['admin', 'users', '1']); + }); + + it('should drop a wildcard that matched nothing', () => { + expect(chainToSegments([{ id: '1', segments: ['*'], params: { pathMatch: '' } }])).toEqual([]); + expect(chainToSegments([{ id: '1', segments: ['admin', '*'], params: undefined }])).toEqual(['admin']); + }); + + it('should keep a mid-path * literally', () => { + expect(chainToSegments([{ id: '1', segments: ['a', '*', 'b'], params: undefined }])).toEqual(['a', '*', 'b']); + }); + it('should return null on missing parameters', () => { const chain: RouteChain = [ { id: '3', segments: ['segment'], params: undefined }, diff --git a/core/src/components/router/utils/constants.ts b/core/src/components/router/utils/constants.ts index ca7fbb8f73f..0bfc3fddf84 100644 --- a/core/src/components/router/utils/constants.ts +++ b/core/src/components/router/utils/constants.ts @@ -1,3 +1,6 @@ export const ROUTER_INTENT_NONE = 'root'; export const ROUTER_INTENT_FORWARD = 'forward'; export const ROUTER_INTENT_BACK = 'back'; + +/** Param that holds the path matched by a trailing `*` in an `ion-route` url. */ +export const WILDCARD_PARAM = 'pathMatch'; diff --git a/core/src/components/router/utils/matching.ts b/core/src/components/router/utils/matching.ts index 2393468347e..9b29a0bacc2 100644 --- a/core/src/components/router/utils/matching.ts +++ b/core/src/components/router/utils/matching.ts @@ -1,4 +1,6 @@ +import { WILDCARD_PARAM } from './constants'; import type { RouteChain, RouteID, RouteRedirect } from './interface'; +import { chainToSegments, endsWithWildcard, withoutWildcard } from './path'; /** * Returns whether the given redirect matches the given path segments. @@ -46,6 +48,10 @@ export const matchesIDs = (ids: Pick[], chain: RouteCh if (routeId.id.toLowerCase() !== routeChain.id) { break; } + // Weight a wildcard route when the outlet reports its `pathMatch` + if (routeId.params?.[WILDCARD_PARAM] !== undefined && endsWithWildcard(routeChain.segments)) { + score++; + } if (routeId.params) { const routeIdParams = Object.keys(routeId.params); // Only compare routes with the chain that have the same number of parameters. @@ -101,20 +107,27 @@ export const matchesSegments = (segments: string[], chain: RouteChain): RouteCha if (chainSegments[0] === '') { matchesDefault = true; } else { - for (const segment of chainSegments) { + const params: { [key: string]: any } = {}; + for (const segment of withoutWildcard(chainSegments)) { const data = inputSegments.next(); // data param if (segment[0] === ':') { if (data === '') { return null; } - allparams = allparams || []; - const params = allparams[i] || (allparams[i] = {}); params[segment.slice(1)] = data; } else if (data !== segment) { return null; } } + // A trailing `*` on a parent route has no effect. Its children match the rest of the path. + if (i === chain.length - 1 && endsWithWildcard(chainSegments)) { + params[WILDCARD_PARAM] = inputSegments.rest().join('/'); + } + if (Object.keys(params).length > 0) { + allparams = allparams || []; + allparams[i] = params; + } matchesDefault = false; } } @@ -152,26 +165,41 @@ export const mergeParams = ( * Returns the best match or null when no match is found. * When a chain is returned the parameters are updated from the RouteIDs. * That is they contain both the componentProps of the and the parameter segment. + * + * When a wildcard route ties with other routes, a route without a wildcard that can build a url wins, + * then the wildcard route. Otherwise the first route declared wins a tie. */ export const findChainForIDs = (ids: RouteID[], chains: RouteChain[]): RouteChain | null => { - let match: RouteChain | null = null; let maxMatches = 0; + let tied: RouteChain[] = []; for (const chain of chains) { const score = matchesIDs(ids, chain); if (score > maxMatches) { - match = chain; maxMatches = score; + tied = [chain]; + } else if (score > 0 && score === maxMatches) { + tied.push(chain); } } - if (match) { - return match.map((route, i) => ({ + const matches = tied.map((chain) => + chain.map((route, i) => ({ id: route.id, segments: route.segments, params: mergeParams(route.params, ids[i]?.params), - })); + })) + ); + if (!matches.some(chainEndsWithWildcard)) { + return matches.length > 0 ? matches[0] : null; } - return null; + return matches.reduce((best, chain) => (tieRank(chain) > tieRank(best) ? chain : best)); +}; + +const tieRank = (chain: RouteChain): number => { + if (chainEndsWithWildcard(chain)) { + return 1; + } + return chainToSegments(chain) !== null ? 2 : 0; }; /** @@ -180,10 +208,17 @@ export const findChainForIDs = (ids: RouteID[], chains: RouteChain[]): RouteChai * Returns the best match or null when no match is found. * When a chain is returned the parameters are updated from the segments. * That is they contain both the componentProps of the and the parameter segments. + * + * When wildcard routes match, the one with the most fixed segments wins if it has more fixed segments than every + * route that matched the whole path. Routes that only matched by ignoring trailing segments don't count. */ export const findChainForSegments = (segments: string[], chains: RouteChain[]): RouteChain | null => { let match: RouteChain | null = null; let bestScore = 0; + let fullMatch: RouteChain | null = null; + let bestFullScore = 0; + let maxFullFixed = -1; + let wildcard: RouteChain | null = null; for (const chain of chains) { const matchedChain = matchesSegments(segments, chain); if (matchedChain !== null) { @@ -192,9 +227,54 @@ export const findChainForSegments = (segments: string[], chains: RouteChain[]): bestScore = score; match = matchedChain; } + if (chainEndsWithWildcard(matchedChain)) { + if (wildcard === null || winsWildcard(matchedChain, wildcard)) { + wildcard = matchedChain; + } + } else if (!isPartialMatch(segments, matchedChain)) { + if (score > bestFullScore) { + bestFullScore = score; + fullMatch = matchedChain; + } + maxFullFixed = Math.max(maxFullFixed, countFixedSegments(matchedChain)); + } } } - return match; + // Without a wildcard to match the rest of the path, routes can still ignore trailing segments. + if (wildcard === null) { + return match; + } + // Compare fixed segments rather than priority, since nesting under default routes raises the priority + return countFixedSegments(wildcard) > maxFullFixed ? wildcard : fullMatch; +}; + +/** Returns whether the chain matched the path segments only by ignoring some trailing segments. */ +const isPartialMatch = (segments: string[], chain: RouteChain): boolean => { + const matched = chainToSegments(chain); + return matched !== null && matched.length < segments.filter((s) => s !== '').length; +}; + +const chainEndsWithWildcard = (chain: RouteChain): boolean => { + return endsWithWildcard(chain[chain.length - 1].segments); +}; + +/** Returns whether the wildcard chain beats `best` by fixed segments, then by priority. */ +const winsWildcard = (chain: RouteChain, best: RouteChain): boolean => { + const chainFixed = countFixedSegments(chain); + const bestFixed = countFixedSegments(best); + return chainFixed > bestFixed || (chainFixed === bestFixed && computePriority(chain) > computePriority(best)); +}; + +const countFixedSegments = (chain: RouteChain): number => { + let count = 0; + for (const route of chain) { + for (const segment of withoutWildcard(route.segments)) { + if (segment !== '' && segment[0] !== ':') { + count++; + } + } + } + return count; }; /** @@ -207,12 +287,14 @@ export const findChainForSegments = (segments: string[], chains: RouteChain[]): * - /path/to/page * * The second one will be given a higher priority because "page" is a fixed segment (vs ":where", a parameter segment). + * + * A trailing wildcard is not counted. */ export const computePriority = (chain: RouteChain): number => { let score = 1; let level = 1; for (const route of chain) { - for (const segment of route.segments) { + for (const segment of withoutWildcard(route.segments)) { if (segment[0] === ':') { score += Math.pow(1, level); } else if (segment !== '') { @@ -236,4 +318,11 @@ export class RouterSegments { } return ''; } + + /** Consumes and returns the remaining non empty segments. */ + rest(): string[] { + const rest = this.segments.filter((s) => s !== ''); + this.segments = []; + return rest; + } } diff --git a/core/src/components/router/utils/path.ts b/core/src/components/router/utils/path.ts index 4f7c3387ca5..8cceb4878bb 100644 --- a/core/src/components/router/utils/path.ts +++ b/core/src/components/router/utils/path.ts @@ -1,5 +1,5 @@ -import { ROUTER_INTENT_FORWARD } from './constants'; -import type { ParsedRoute, RouteChain, RouterDirection } from './interface'; +import { ROUTER_INTENT_FORWARD, WILDCARD_PARAM } from './constants'; +import type { ParsedRoute, RouteChain, RouteEntry, RouterDirection } from './interface'; /** Join the non empty segments with "/". */ export const generatePath = (segments: string[]): string => { @@ -45,12 +45,13 @@ export const writeSegments = ( * * Notes: * - parameter segments of the form :param are replaced with their value, - * - null is returned when a value is missing for any parameter segment. + * - null is returned when a value is missing for any parameter segment, + * - a trailing wildcard is replaced with the path it matched. */ export const chainToSegments = (chain: RouteChain): string[] | null => { const segments = []; for (const route of chain) { - for (const segment of route.segments) { + for (const segment of withoutWildcard(route.segments)) { if (segment[0] === ':') { const param = route.params && route.params[segment.slice(1)]; if (!param) { @@ -61,10 +62,26 @@ export const chainToSegments = (chain: RouteChain): string[] | null => { segments.push(segment); } } + segments.push(...wildcardSegments(route)); } return segments; }; +/** Only a `*` in the last segment of a route url is a wildcard. Elsewhere it's matched literally. */ +export const endsWithWildcard = (segments: string[]): boolean => { + return segments[segments.length - 1] === '*'; +}; + +/** Returns the path segments matched by the trailing wildcard of the route. */ +const wildcardSegments = (route: RouteEntry): string[] => { + const pathMatch: string | undefined = endsWithWildcard(route.segments) ? route.params?.[WILDCARD_PARAM] : undefined; + return pathMatch ? pathMatch.split('/').filter((s) => s.length > 0) : []; +}; + +export const withoutWildcard = (segments: string[]): string[] => { + return endsWithWildcard(segments) ? segments.slice(0, -1) : segments; +}; + /** * Removes the prefix segments from the path segments. *