Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down Expand Up @@ -7896,14 +7896,14 @@ declare namespace LocalJSX {
*/
"onIonRouteDataChanged"?: (event: IonRouteCustomEvent<any>) => 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;
/**
Expand Down
4 changes: 3 additions & 1 deletion core/src/components/route-redirect/route-redirect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions core/src/components/route/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '';

Expand Down
83 changes: 83 additions & 0 deletions core/src/components/router/test/catch-all/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Router - Catch-all</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
<script>
const createPage = (title, body = () => '') =>
class extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<ion-header>
<ion-toolbar>
<ion-title>${title}</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>${body(this)}</ion-content>`;
}
};

const pathMatch = (page) => `<p>Path: <span class="path-match">${page.pathMatch}</span></p>`;

class TabsPage extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<ion-tabs>
<ion-tab tab="tab-home" component="tab-home"></ion-tab>
<ion-tab tab="tab-docs" component="tab-docs"></ion-tab>

<ion-tab-bar slot="bottom">
<ion-tab-button tab="tab-home">
<ion-label>Home</ion-label>
</ion-tab-button>
<ion-tab-button tab="tab-docs">
<ion-label>Docs</ion-label>
</ion-tab-button>
</ion-tab-bar>
</ion-tabs>`;
}
}

class TabDocs extends HTMLElement {
connectedCallback() {
this.innerHTML = `<ion-router-outlet></ion-router-outlet>`;
}
}

customElements.define('tabs-page', TabsPage);
customElements.define('tab-home', createPage('Home'));
customElements.define('tab-docs', TabDocs);
customElements.define('docs-index', createPage('Docs'));
customElements.define('docs-intro', createPage('Intro'));
customElements.define('docs-not-found', createPage('Docs Not Found', pathMatch));
customElements.define('not-found-page', createPage('Not Found', pathMatch));
</script>
</head>

<body>
<ion-app>
<ion-router use-hash="true">
<ion-route url="/" component="tabs-page">
<ion-route url="/" component="tab-home"></ion-route>
<ion-route url="/docs" component="tab-docs">
<ion-route url="/" component="docs-index"></ion-route>
<ion-route url="/intro" component="docs-intro"></ion-route>
<ion-route url="/*" component="docs-not-found"></ion-route>
</ion-route>
</ion-route>
<ion-route url="*" component="not-found-page"></ion-route>
</ion-router>

<ion-router-outlet></ion-router-outlet>
</ion-app>
</body>
</html>
66 changes: 66 additions & 0 deletions core/src/components/router/test/catch-all/router.e2e.ts
Original file line number Diff line number Diff line change
@@ -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$/);
});
});
});
Loading
Loading