diff --git a/BREAKING.md b/BREAKING.md index 8076a0dc981..0c7b726aad1 100644 --- a/BREAKING.md +++ b/BREAKING.md @@ -20,6 +20,7 @@ This is a comprehensive list of the breaking changes introduced in the major ver - [Input](#version-9x-input) - [Legacy Picker](#version-9x-legacy-picker) - [Modal](#version-9x-modal) + - [Nav](#version-9x-nav) - [Router Outlet](#version-9x-router-outlet) - [Searchbar](#version-9x-searchbar) - [Select](#version-9x-select) @@ -98,6 +99,32 @@ Sheet modals that relied on the handle being inert should set `handleBehavior="n ``` +

Nav

+ +`ion-nav` no longer integrates with `ion-router`. It is now a standalone imperative stack navigation component, driven only through its own API (`root`, `push`, `pop`, `setRoot`, etc.) and `ion-nav-link`. + +The following behaviors have been removed: + +- The router no longer discovers or drives an `ion-nav`. Placing an `ion-nav` inside an `ion-router` no longer turns it into a routed outlet. +- Navigating an `ion-nav` (via `push`, `pop`, `ion-nav-link`, or the swipe-to-go-back gesture) no longer updates the URL, and the router's navigation guards no longer run for `ion-nav` transitions. +- The internal `setRouteId()` and `getRouteId()` methods and the `updateURL` nav option have been removed. + +Apps that relied on `ion-nav` to update the URL (for example, pushing components and expecting the browser URL to change) should use `ion-router-outlet` for URL-based routing: + +```diff +- +- +- ++ +``` + +An `ion-nav` can still be used inside a routed page for local, URL-less stack navigation: + +```html + + +``` +

Router Outlet

`ion-router-outlet` now exposes a `swipeGesture` property that controls the swipe-to-go-back gesture per outlet. This property defaults to `true` in `"ios"` mode and `false` in `"md"` mode. diff --git a/core/src/components.d.ts b/core/src/components.d.ts index ac1c904ff6d..342d525d792 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -2115,10 +2115,6 @@ export namespace Components { * @param view The view to get. */ "getPrevious": (view?: ViewController) => Promise; - /** - * Called by to retrieve the current component. - */ - "getRouteId": () => Promise; /** * Inserts a component into the navigation stack at the specified index. This is useful to add a component at any point in the navigation stack. * @param insertIndex The index to insert the component at in the stack. @@ -2194,15 +2190,6 @@ export namespace Components { * @param done The transition complete function. */ "setRoot": (component: T, componentProps?: ComponentProps | null, opts?: NavOptions | null, done?: TransitionDoneFn) => Promise; - /** - * Called by the router to update the view. - * @param id The component tag. - * @param params The component params. - * @param direction A direction hint. - * @param animation an AnimationBuilder. - * @return the status. - */ - "setRouteId": (id: string, params: ComponentProps | undefined, direction: RouterDirection, animation?: AnimationBuilder) => Promise; /** * If the nav component should allow for swipe-to-go-back. */ @@ -2724,7 +2711,7 @@ export namespace Components { */ "beforeLeave"?: NavigationHookCallback; /** - * Name of the component to load/select in the navigation outlet (`ion-tabs`, `ion-nav`) when the route matches. The value of this property is not always the tagname of the component to load, in `ion-tabs` it actually refers to the name of the `ion-tab` to select. + * Name of the component to load/select in the navigation outlet (`ion-tabs`, `ion-router-outlet`) when the route matches. The value of this property is not always the tagname of the component to load, in `ion-tabs` it actually refers to the name of the `ion-tab` to select. */ "component": string; /** @@ -7897,7 +7884,7 @@ declare namespace LocalJSX { */ "beforeLeave"?: NavigationHookCallback; /** - * Name of the component to load/select in the navigation outlet (`ion-tabs`, `ion-nav`) when the route matches. The value of this property is not always the tagname of the component to load, in `ion-tabs` it actually refers to the name of the `ion-tab` to select. + * Name of the component to load/select in the navigation outlet (`ion-tabs`, `ion-router-outlet`) when the route matches. The value of this property is not always the tagname of the component to load, in `ion-tabs` it actually refers to the name of the `ion-tab` to select. */ "component": string; /** diff --git a/core/src/components/nav/nav-interface.ts b/core/src/components/nav/nav-interface.ts index caacb39b2ec..07515625e75 100644 --- a/core/src/components/nav/nav-interface.ts +++ b/core/src/components/nav/nav-interface.ts @@ -42,7 +42,6 @@ export interface RouterOutletOptions { export interface NavOptions extends RouterOutletOptions { progressAnimation?: boolean; - updateURL?: boolean; delegate?: FrameworkDelegate; viewIsReady?: (enteringEl: HTMLElement) => Promise; } diff --git a/core/src/components/nav/nav.tsx b/core/src/components/nav/nav.tsx index 3ecd39c2aa6..3b40c13e427 100644 --- a/core/src/components/nav/nav.tsx +++ b/core/src/components/nav/nav.tsx @@ -1,5 +1,5 @@ -import type { EventEmitter } from '@stencil/core'; -import { Build, Component, Element, Event, Method, Prop, Watch, h } from '@stencil/core'; +import type { ComponentInterface, EventEmitter } from '@stencil/core'; +import { Component, Element, Event, Method, Prop, Watch, h } from '@stencil/core'; import { getTimeGivenProgression } from '@utils/animation/cubic-bezier'; import { assert } from '@utils/helpers'; import { printIonWarning } from '@utils/logging'; @@ -9,7 +9,6 @@ import { lifecycle, setPageHidden, transition } from '@utils/transition'; import { config } from '../../global/config'; import { getIonMode } from '../../global/ionic-global'; import type { Animation, AnimationBuilder, ComponentProps, FrameworkDelegate, Gesture } from '../../interface'; -import type { NavOutlet, RouteID, RouteWrite, RouterDirection } from '../router/utils/interface'; import { LIFECYCLE_DID_LEAVE, LIFECYCLE_WILL_LEAVE, LIFECYCLE_WILL_UNLOAD } from './constants'; import type { @@ -21,18 +20,17 @@ import type { TransitionInstruction, } from './nav-interface'; import type { ViewController } from './view-controller'; -import { VIEW_STATE_ATTACHED, VIEW_STATE_DESTROYED, VIEW_STATE_NEW, convertToViews, matches } from './view-controller'; +import { VIEW_STATE_ATTACHED, VIEW_STATE_DESTROYED, VIEW_STATE_NEW, convertToViews } from './view-controller'; @Component({ tag: 'ion-nav', styleUrl: 'nav.scss', shadow: true, }) -export class Nav implements NavOutlet { +export class Nav implements ComponentInterface { private transInstr: TransitionInstruction[] = []; private sbAni?: Animation; private gestureOrAnimationInProgress = false; - private useRouter = false; private isTransitioning = false; private destroyed = false; private views: ViewController[] = []; @@ -78,8 +76,6 @@ export class Nav implements NavOutlet { @Watch('root') rootChanged() { - const isDev = Build.isDev; - if (this.root === undefined) { return; } @@ -92,13 +88,7 @@ export class Nav implements NavOutlet { return; } - if (!this.useRouter) { - if (this.root !== undefined) { - this.setRoot(this.root, this.rootParams); - } - } else if (isDev) { - printIonWarning('[ion-nav] - A root attribute is not supported when using ion-router.', this.el); - } + this.setRoot(this.root, this.rootParams); } /** @@ -116,8 +106,6 @@ export class Nav implements NavOutlet { @Event({ bubbles: false }) ionNavDidChange!: EventEmitter; componentWillLoad() { - this.useRouter = document.querySelector('ion-router') !== null && this.el.closest('[no-router]') === null; - if (this.swipeGesture === undefined) { const mode = getIonMode(this); this.swipeGesture = config.getBoolean('swipeBackEnabled', mode === 'ios'); @@ -352,99 +340,6 @@ export class Nav implements NavOutlet { ); } - /** - * Called by the router to update the view. - * - * @param id The component tag. - * @param params The component params. - * @param direction A direction hint. - * @param animation an AnimationBuilder. - * - * @return the status. - * @internal - */ - @Method() - setRouteId( - id: string, - params: ComponentProps | undefined, - direction: RouterDirection, - animation?: AnimationBuilder - ): Promise { - const active = this.getActiveSync(); - if (matches(active, id, params)) { - return Promise.resolve({ - changed: false, - element: active.element, - }); - } - - let resolve: (result: RouteWrite) => void; - const promise = new Promise((r) => (resolve = r)); - let finish: Promise; - const commonOpts: NavOptions = { - updateURL: false, - viewIsReady: (enteringEl) => { - let mark: () => void; - const p = new Promise((r) => (mark = r)); - resolve({ - changed: true, - element: enteringEl, - markVisible: async () => { - mark(); - await finish; - }, - }); - return p; - }, - }; - - if (direction === 'root') { - finish = this.setRoot(id, params, commonOpts); - } else { - // Look for a view matching the target in the view stack. - const viewController = this.views.find((v) => matches(v, id, params)); - - if (viewController) { - finish = this.popTo(viewController, { - ...commonOpts, - direction: 'back', - animationBuilder: animation, - }); - } else if (direction === 'forward') { - finish = this.push(id, params, { - ...commonOpts, - animationBuilder: animation, - }); - } else if (direction === 'back') { - finish = this.setRoot(id, params, { - ...commonOpts, - direction: 'back', - animated: true, - animationBuilder: animation, - }); - } - } - return promise; - } - - /** - * Called by to retrieve the current component. - * - * @internal - */ - @Method() - async getRouteId(): Promise { - const active = this.getActiveSync(); - if (active) { - return { - id: active.element!.tagName, - params: active.params, - element: active.element, - }; - } - return undefined; - } - /** * Get the active view. */ @@ -524,26 +419,6 @@ export class Nav implements NavOutlet { }); ti.done = done; - /** - * If using router, check to see if navigation hooks - * will allow us to perform this transition. This - * is required in order for hooks to work with - * the ion-back-button or swipe to go back. - */ - if (ti.opts && ti.opts.updateURL !== false && this.useRouter) { - const router = document.querySelector('ion-router'); - if (router) { - const canTransition = await router.canTransition(); - if (canTransition === false) { - return false; - } - if (typeof canTransition === 'string') { - router.push(canTransition, ti.opts!.direction || 'back'); - return false; - } - } - } - // Normalize empty if (ti.insertViews?.length === 0) { ti.insertViews = undefined; @@ -575,14 +450,6 @@ export class Nav implements NavOutlet { ); } ti.resolve!(result.hasCompleted); - - if (ti.opts!.updateURL !== false && this.useRouter) { - const router = document.querySelector('ion-router'); - if (router) { - const direction = result.direction === 'back' ? 'back' : 'forward'; - router.navChanged(direction); - } - } } private failed(rejectReason: any, ti: TransitionInstruction) { diff --git a/core/src/components/nav/test/routing/index.html b/core/src/components/nav/test/routing/index.html index 4621a08a9d7..d4aa5c53607 100644 --- a/core/src/components/nav/test/routing/index.html +++ b/core/src/components/nav/test/routing/index.html @@ -15,14 +15,21 @@ + - + - - + diff --git a/core/src/components/nav/test/routing/nav.e2e.ts b/core/src/components/nav/test/routing/nav.e2e.ts index 7d533d52139..369dcb453fb 100644 --- a/core/src/components/nav/test/routing/nav.e2e.ts +++ b/core/src/components/nav/test/routing/nav.e2e.ts @@ -1,10 +1,13 @@ import { expect } from '@playwright/test'; import { configs, test } from '@utils/test/playwright'; -// Tests for ion-nav used in ion-router - /** - * This behavior does not vary across modes/directions + * As of Ionic 9, ion-nav is a standalone stack navigation component and no longer + * integrates with ion-router. These tests verify that an ion-nav still manages its + * own stack when an ion-router is present on the page, and that navigating the nav + * never syncs to the router (the URL does not change). + * + * This behavior does not vary across modes/directions. */ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { test.describe(title('nav: routing'), () => { @@ -12,50 +15,28 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => await page.goto('/src/components/nav/test/routing', config); }); - test('should render the root component', async ({ page }) => { + test('should render the root component from the root property', async ({ page }) => { const pageRoot = page.locator('page-root'); await expect(pageRoot).toBeVisible(); }); - test.describe('pushing a new page', () => { - test('should render the pushed component', async ({ page }) => { - const pageRoot = page.locator('page-root'); - const pageOne = page.locator('page-one'); - const pageTwo = page.locator('page-two'); - - const pageOneButton = page.locator('button:has-text("Go to Page One")'); - const pageTwoButton = page.locator('button:has-text("Go to Page Two")'); - - await pageOneButton.click(); - await page.waitForChanges(); - - await expect(pageOne).toBeVisible(); - // Pushing a new page should hide the previous page - await expect(pageRoot).not.toBeVisible(); - await expect(pageRoot).toHaveCount(1); - - await pageTwoButton.click(); - await page.waitForChanges(); + test('pushing a page should not update the URL', async ({ page }) => { + const urlBefore = page.url(); - await expect(pageTwo).toBeVisible(); - // Pushing a new page should hide the previous page - await expect(pageOne).not.toBeVisible(); - await expect(pageOne).toHaveCount(1); - }); - - test('should render the back button', async ({ page }) => { - const pageOneButton = page.locator('button:has-text("Go to Page One")'); - const pageOneBackButton = page.locator('page-one ion-back-button'); + const pageOne = page.locator('page-one'); + const pageOneButton = page.locator('button:has-text("Go to Page One")'); - await pageOneButton.click(); - await page.waitForChanges(); + await pageOneButton.click(); + await page.waitForChanges(); - await expect(pageOneBackButton).toBeVisible(); - }); + // The nav stack advances... + await expect(pageOne).toBeVisible(); + // ...but the router is not involved, so the URL is unchanged. + expect(page.url()).toBe(urlBefore); }); - test('back button should pop to the previous page', async ({ page }) => { + test('back button should pop the nav stack without touching the URL', async ({ page }) => { const pageRoot = page.locator('page-root'); const pageOne = page.locator('page-one'); @@ -65,46 +46,38 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => await pageOneButton.click(); await page.waitForChanges(); + const urlAfterPush = page.url(); + await pageOneBackButton.click(); await page.waitForChanges(); await expect(pageRoot).toBeVisible(); - // Popping a page should remove it from the DOM + // Popping a page removes it from the DOM. await expect(pageOne).toHaveCount(0); + // The router never observed the pop, so the URL is unchanged. + expect(page.url()).toBe(urlAfterPush); }); - test.describe('pushing multiple pages', () => { - test('should keep previous pages in the DOM', async ({ page }) => { - const pageRoot = page.locator('page-root'); - const pageOne = page.locator('page-one'); - const pageTwo = page.locator('page-two'); - const pageThree = page.locator('page-three'); - - const pageOneButton = page.locator('button:has-text("Go to Page One")'); - const pageTwoButton = page.locator('button:has-text("Go to Page Two")'); - const pageThreeButton = page.locator('button:has-text("Go to Page Three")'); - - await pageOneButton.click(); - await page.waitForChanges(); + test('pushing multiple pages should keep previous pages in the DOM', async ({ page }) => { + const pageRoot = page.locator('page-root'); + const pageOne = page.locator('page-one'); + const pageTwo = page.locator('page-two'); - await expect(pageRoot).toHaveCount(1); - await expect(pageOne).toBeVisible(); + const pageOneButton = page.locator('button:has-text("Go to Page One")'); + const pageTwoButton = page.locator('button:has-text("Go to Page Two")'); - await pageTwoButton.click(); - await page.waitForChanges(); + await pageOneButton.click(); + await page.waitForChanges(); - await expect(pageRoot).toHaveCount(1); - await expect(pageOne).toHaveCount(1); - await expect(pageTwo).toBeVisible(); + await expect(pageRoot).toHaveCount(1); + await expect(pageOne).toBeVisible(); - await pageThreeButton.click(); - await page.waitForChanges(); + await pageTwoButton.click(); + await page.waitForChanges(); - await expect(pageRoot).toHaveCount(1); - await expect(pageOne).toHaveCount(1); - await expect(pageTwo).toHaveCount(1); - await expect(pageThree).toBeVisible(); - }); + await expect(pageRoot).toHaveCount(1); + await expect(pageOne).toHaveCount(1); + await expect(pageTwo).toBeVisible(); }); }); }); diff --git a/core/src/components/route/route.tsx b/core/src/components/route/route.tsx index f9ecf90153f..5010cac4899 100644 --- a/core/src/components/route/route.tsx +++ b/core/src/components/route/route.tsx @@ -18,7 +18,7 @@ export class Route implements ComponentInterface { @Prop() url = ''; /** - * Name of the component to load/select in the navigation outlet (`ion-tabs`, `ion-nav`) + * Name of the component to load/select in the navigation outlet (`ion-tabs`, `ion-router-outlet`) * when the route matches. * * The value of this property is not always the tagname of the component to load, diff --git a/core/src/components/router/test/basic/index.html b/core/src/components/router/test/basic/index.html index 12d459caa50..4a426a1699b 100644 --- a/core/src/components/router/test/basic/index.html +++ b/core/src/components/router/test/basic/index.html @@ -95,7 +95,7 @@

Anchor target

- + `; } } @@ -222,7 +222,7 @@

Anchor target

- +