From ecd6f5a7c0ed987f4357b7a0e3954d9291be170d Mon Sep 17 00:00:00 2001 From: Brandy Smith Date: Thu, 15 Oct 2026 14:15:39 +0000 Subject: [PATCH 01/12] fix(input, select, textarea): prevent floating label from overflowing while it floats (#31567) Issue number: internal --------- ## What is the current behavior? An input, select, or textarea with an extremely long overflowing label with `label-placement="floating"` overflows its container when going from a resting to a floating state: https://github.com/user-attachments/assets/7fc7f1e3-07d6-4461-ad63-261f2ebcf57f ## What is the new behavior? https://github.com/user-attachments/assets/aae95b06-5cdf-4b9e-81ee-48e3e6b3fb39 - The label's `max-width` now transitions along with its `transform`, so the label reveals more of its text gradually as it scales down instead of showing its full floating width while still at its resting size. - With `fill="outline"`, the label stays positioned relative to the control in both its resting and floating states, so its `max-width` always resolves against the same width and can animate without jumping. - The start container controller now also measures the end container and is renamed to `createStartEndContainerController`. The combined start and end width is written to `--internal-start-end-container-width`, which the outline floating label uses to size itself to the full width of the notch. - Adds `$form-control-label-transition-easing` so the label's color, transform, and `max-width` transitions share one easing. - Adds an e2e test per component that steps through the label's transitions and checks that the label never extends past the field. ## Does this introduce a breaking change? - [ ] Yes - [x] No ## Other information Previews: - [Input](https://ionic-framework-git-fw-7683-ionic1.vercel.app/src/components/input/test/slot) - [Select](https://ionic-framework-git-fw-7683-ionic1.vercel.app/src/components/select/test/slot) - [Textarea](https://ionic-framework-git-fw-7683-ionic1.vercel.app/src/components/textarea/test/slot) --------- Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> --- .../components/input/input.md.outline.scss | 34 ++- core/src/components/input/input.scss | 10 +- core/src/components/input/input.tsx | 24 +- .../components/input/test/slot/input.e2e.ts | 83 +++++++ .../components/select/select.md.outline.scss | 34 ++- core/src/components/select/select.scss | 10 +- core/src/components/select/select.tsx | 24 +- .../components/select/test/slot/select.e2e.ts | 84 +++++++ .../textarea/test/slot/textarea.e2e.ts | 83 +++++++ .../textarea/textarea.md.outline.scss | 36 +-- core/src/components/textarea/textarea.scss | 10 +- core/src/components/textarea/textarea.tsx | 24 +- core/src/themes/ionic.globals.scss | 4 + core/src/utils/forms/index.ts | 2 +- .../utils/forms/start-container-controller.ts | 163 -------------- .../forms/start-end-container-controller.ts | 205 ++++++++++++++++++ ...=> start-end-container-controller.spec.ts} | 135 ++++++++++-- 17 files changed, 697 insertions(+), 268 deletions(-) delete mode 100644 core/src/utils/forms/start-container-controller.ts create mode 100644 core/src/utils/forms/start-end-container-controller.ts rename core/src/utils/forms/test/{start-container-controller.spec.ts => start-end-container-controller.spec.ts} (71%) diff --git a/core/src/components/input/input.md.outline.scss b/core/src/components/input/input.md.outline.scss index d6cda674e58..bbd4360f8e1 100644 --- a/core/src/components/input/input.md.outline.scss +++ b/core/src/components/input/input.md.outline.scss @@ -11,13 +11,22 @@ /** * The measured width of the start container, as a negative offset - * in LTR and a positive one in RTL. Written to the host by the - * start container controller, so renaming it here requires renaming + * in LTR and a positive one in RTL. Written to the host by the start + * and end container controller, so renaming it here requires renaming * it there too. Declared with a default so the label is positioned * correctly before the first measurement runs. */ --internal-start-container-adjustment: 0px; + /** + * The measured combined width of the start and end containers. + * Written to the host by the start and end container controller, so + * renaming it here requires renaming it there too. Declared with a + * default so the floating label falls back to the width of the + * control before the first measurement runs. + */ + --internal-start-end-container-width: 0px; + min-height: 56px; } @@ -74,11 +83,12 @@ } /** - * The resting label is positioned relative to the control so it can size to - * the space between the start and end slots. When floating, the label needs - * to escape the control and position itself relative to the wrapper instead. + * The label is positioned relative to the control whether it is resting + * over the input or floating in the notch, so its max-width always resolves + * against the same width. This lets the max-width animate between the + * resting and floating values without jumping. */ -:host(.input-fill-outline:not(.label-floating)) .input-control { +:host(.input-fill-outline) .input-control { position: relative; } @@ -89,9 +99,8 @@ position: absolute; /** - * While resting this is the width of the text input, which already - * excludes the start and end slots. Once the label floats the - * containing block becomes the wrapper and the rule below takes over. + * This is the width of the text input, which already + * excludes the start and end slots. */ max-width: 100%; } @@ -113,10 +122,11 @@ @include margin(0); /** - * Label text should not extend - * beyond the bounds of the input. + * Label text should not extend beyond the bounds of the input. + * The control excludes the start and end slots, but the notch spans + * the full width of the outline, so their width is added back. */ - max-width: calc((100% - var(--padding-start) - var(--padding-end) - #{$input-md-floating-label-padding * 2}) / #{$form-control-label-stacked-scale}); + max-width: calc((100% + var(--internal-start-end-container-width) - #{$input-md-floating-label-padding * 2}) / #{$form-control-label-stacked-scale}); } // Input Fill: Outline Outline Container diff --git a/core/src/components/input/input.scss b/core/src/components/input/input.scss index f5d02058228..dbf93e7cfe3 100644 --- a/core/src/components/input/input.scss +++ b/core/src/components/input/input.scss @@ -466,7 +466,11 @@ */ max-width: 200px; - transition: color 150ms cubic-bezier(.4, 0, .2, 1), transform 150ms cubic-bezier(.4, 0, .2, 1); + /** + * The max-width animates with the transform so the label does not + * reveal its full floating width while it is still at full size. + */ + transition: color 150ms #{$form-control-label-transition-easing}, transform 150ms #{$form-control-label-transition-easing}, max-width 150ms #{$form-control-label-transition-easing}; /** * This ensures that double tapping this text @@ -481,8 +485,8 @@ * disable the label transition to prevent the floating label * from animating as its position is adjusted. * - * This class is applied by the start container controller, so - * renaming it here requires renaming it there too. + * This class is applied by the start and end container controller, + * so renaming it here requires renaming it there too. */ :host(.skip-label-transition) .label-text-wrapper { transition: none; diff --git a/core/src/components/input/input.tsx b/core/src/components/input/input.tsx index 2dc919e41b1..9e8103432e6 100644 --- a/core/src/components/input/input.tsx +++ b/core/src/components/input/input.tsx @@ -13,11 +13,11 @@ import { forceUpdate, h, } from '@stencil/core'; -import type { ClickController, NotchController, StartContainerController } from '@utils/forms'; +import type { ClickController, NotchController, StartEndContainerController } from '@utils/forms'; import { createClickController, createNotchController, - createStartContainerController, + createStartEndContainerController, checkInvalidState, } from '@utils/forms'; import type { Attributes } from '@utils/helpers'; @@ -59,8 +59,9 @@ export class Input implements ComponentInterface { private slotMutationController?: SlotMutationController; private notchController?: NotchController; private notchSpacerEl: HTMLElement | undefined; - private startContainerController?: StartContainerController; + private startEndContainerController?: StartEndContainerController; private startContainerEl: HTMLElement | undefined; + private endContainerEl: HTMLElement | undefined; private clickController?: ClickController; private originalIonInput?: EventEmitter; @@ -412,7 +413,7 @@ export class Input implements ComponentInterface { const { el } = this; this.slotMutationController = createSlotMutationController(el, ['label', 'start', 'end'], () => { - this.startContainerController?.calculateStartContainerWidth(); + this.startEndContainerController?.calculateContainerWidths(); this.setSlottedLabelId(); forceUpdate(this); @@ -425,15 +426,16 @@ export class Input implements ComponentInterface { () => this.labelSlot ); - this.startContainerController = createStartContainerController( + this.startEndContainerController = createStartEndContainerController( el, () => this.startContainerEl, + () => this.endContainerEl, () => { return Build.isBrowser && getIonMode(this) === 'md' && this.fill === 'outline'; } ); - this.startContainerController.calculateStartContainerWidth(); + this.startEndContainerController.calculateContainerWidths(); this.clickController = createClickController(el, () => this.nativeInput); @@ -482,7 +484,7 @@ export class Input implements ComponentInterface { componentDidRender() { this.notchController?.calculateNotchWidth(); - this.startContainerController?.calculateStartContainerWidth(); + this.startEndContainerController?.calculateContainerWidths(); } disconnectedCallback() { @@ -504,9 +506,9 @@ export class Input implements ComponentInterface { this.notchController = undefined; } - if (this.startContainerController) { - this.startContainerController.destroy(); - this.startContainerController = undefined; + if (this.startEndContainerController) { + this.startEndContainerController.destroy(); + this.startEndContainerController = undefined; } // Clean up validation observer to prevent memory leaks @@ -946,7 +948,7 @@ export class Input implements ComponentInterface { /> -
+
(this.endContainerEl = el)}> {this.clearInput && !readonly && !disabled && (
-
+
(this.endContainerEl = el)}> {/** * The icon is rendered in the end container when the * select has a floating or stacked label so it is diff --git a/core/src/components/select/test/slot/select.e2e.ts b/core/src/components/select/test/slot/select.e2e.ts index 3f12c760117..dae11c5a79c 100644 --- a/core/src/components/select/test/slot/select.e2e.ts +++ b/core/src/components/select/test/slot/select.e2e.ts @@ -513,6 +513,90 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co expect(box!.width).toBeGreaterThanOrEqual(twoCharacterWidth); }); }); + + /** + * The label truncates beside the wide slot and has room for its full + * text once it floats into the notch. It must not reveal that full text + * while it is still at its resting size, or it overflows the select + * partway through the transition. + */ + test('should keep the label inside the select while it floats above a wide end slot', async ({ page }) => { + /** + * The label is longer than the select, so revealing all of it at its + * resting size pushes it past the border, not only over the slot. + */ + await page.setContent( + ` + + ${endSlot} + 100 + + `, + config + ); + + const select = page.locator('ion-select'); + + /** + * The first measurement of the start container disables the label + * transition for a frame. Wait for it to finish so the label is not + * floated while its transitions are turned off. + */ + await expect(select).toHaveAttribute('style', /--internal-start-container-adjustment/); + await expect(select).not.toHaveClass(/skip-label-transition/); + + /** + * Pauses the label's transitions and steps through them so every + * point of the animation is checked regardless of frame timing. + */ + const overflow = await select.evaluate(async (el: HTMLIonSelectElement) => { + const label = el.shadowRoot!.querySelector('.label-text-wrapper')!; + const wrapper = el.shadowRoot!.querySelector('.select-wrapper')!; + const maxRight = wrapper.getBoundingClientRect().right - parseFloat(getComputedStyle(wrapper).paddingRight); + + /** + * The class that starts the transitions is added on the next render. + * A mutation observer runs right after that render, before the next + * frame, so the transitions are caught at their start. Waiting for a + * frame or using `page.waitForChanges()` can let the 150ms transitions + * finish on a slow machine, at which point the browser discards them + * and there is nothing to step through. + */ + await new Promise((resolve) => { + const observer = new MutationObserver(() => { + if (el.classList.contains('label-floating')) { + observer.disconnect(); + resolve(); + } + }); + observer.observe(el, { attributeFilter: ['class'] }); + + el.value = '100'; + }); + + // Without this a label that already finished floating would pass + const transitions = label.getAnimations(); + if (transitions.length === 0) { + throw new Error('The label did not transition'); + } + transitions.forEach((transition) => transition.pause()); + + // Step through the 150ms label transition in 15ms increments + const overflows = Array.from({ length: 11 }, (_, step) => { + transitions.forEach((transition) => (transition.currentTime = step * 15)); + return label.getBoundingClientRect().right - maxRight; + }); + + return Math.max(...overflows); + }); + + expect(overflow).toBeLessThanOrEqual(1); + }); }); }); diff --git a/core/src/components/textarea/test/slot/textarea.e2e.ts b/core/src/components/textarea/test/slot/textarea.e2e.ts index 87c15eca153..14e30727d1b 100644 --- a/core/src/components/textarea/test/slot/textarea.e2e.ts +++ b/core/src/components/textarea/test/slot/textarea.e2e.ts @@ -343,6 +343,89 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co expect(box!.width).toBeGreaterThanOrEqual(twoCharacterWidth); }); }); + + /** + * The label truncates beside the wide slot and has room for its full + * text once it floats into the notch. It must not reveal that full text + * while it is still at its resting size, or it overflows the textarea + * partway through the transition. + */ + test('should keep the label inside the textarea while it floats above a wide end slot', async ({ page }) => { + /** + * The label is longer than the textarea, so revealing all of it at its + * resting size pushes it past the border, not only over the slot. + */ + await page.setContent( + ` + + ${endSlot} + + `, + config + ); + + const textarea = page.locator('ion-textarea'); + + /** + * The first measurement of the start container disables the label + * transition for a frame. Wait for it to finish so the label is not + * floated while its transitions are turned off. + */ + await expect(textarea).toHaveAttribute('style', /--internal-start-container-adjustment/); + await expect(textarea).not.toHaveClass(/skip-label-transition/); + + /** + * Pauses the label's transitions and steps through them so every + * point of the animation is checked regardless of frame timing. + */ + const overflow = await textarea.evaluate(async (el: HTMLIonTextareaElement) => { + const label = el.querySelector('.label-text-wrapper')!; + const wrapper = el.querySelector('.textarea-wrapper')!; + const maxRight = wrapper.getBoundingClientRect().right - parseFloat(getComputedStyle(wrapper).paddingRight); + + /** + * The class that starts the transitions is added on the next render. + * A mutation observer runs right after that render, before the next + * frame, so the transitions are caught at their start. Waiting for a + * frame or using `page.waitForChanges()` can let the 150ms transitions + * finish on a slow machine, at which point the browser discards them + * and there is nothing to step through. + */ + await new Promise((resolve) => { + const observer = new MutationObserver(() => { + if (el.classList.contains('label-floating')) { + observer.disconnect(); + resolve(); + } + }); + observer.observe(el, { attributeFilter: ['class'] }); + + el.value = '100'; + }); + + // Without this a label that already finished floating would pass + const transitions = label.getAnimations(); + if (transitions.length === 0) { + throw new Error('The label did not transition'); + } + transitions.forEach((transition) => transition.pause()); + + // Step through the 150ms label transition in 15ms increments + const overflows = Array.from({ length: 11 }, (_, step) => { + transitions.forEach((transition) => (transition.currentTime = step * 15)); + return label.getBoundingClientRect().right - maxRight; + }); + + return Math.max(...overflows); + }); + + expect(overflow).toBeLessThanOrEqual(1); + }); }); }); diff --git a/core/src/components/textarea/textarea.md.outline.scss b/core/src/components/textarea/textarea.md.outline.scss index 6452987a2b2..00d79728edd 100644 --- a/core/src/components/textarea/textarea.md.outline.scss +++ b/core/src/components/textarea/textarea.md.outline.scss @@ -11,12 +11,21 @@ /** * The measured width of the start container, as a negative offset - * in LTR and a positive one in RTL. Written to the host by the - * start container controller, so renaming it here requires renaming + * in LTR and a positive one in RTL. Written to the host by the start + * and end container controller, so renaming it here requires renaming * it there too. Declared with a default so the label is positioned * correctly before the first measurement runs. */ --internal-start-container-adjustment: 0px; + + /** + * The measured combined width of the start and end containers. + * Written to the host by the start and end container controller, so + * renaming it here requires renaming it there too. Declared with a + * default so the floating label falls back to the width of the + * control before the first measurement runs. + */ + --internal-start-end-container-width: 0px; } :host(.textarea-fill-outline.textarea-shape-round) { @@ -72,11 +81,12 @@ } /** - * The resting label is positioned relative to the control so it can size to - * the space between the start and end slots. When floating, the label needs - * to escape the control and position itself relative to the wrapper instead. + * The label is positioned relative to the control whether it is resting + * over the textarea or floating in the notch, so its max-width always resolves + * against the same width. This lets the max-width animate between the + * resting and floating values without jumping. */ -:host(.textarea-fill-outline:not(.label-floating)) .textarea-control { +:host(.textarea-fill-outline) .textarea-control { position: relative; } @@ -87,9 +97,8 @@ position: absolute; /** - * While resting this is the width of the textarea, which already - * excludes the start and end slots. Once the label floats the - * containing block becomes the wrapper and the rule below takes over. + * This is the width of the textarea, which already + * excludes the start and end slots. */ max-width: 100%; } @@ -111,12 +120,11 @@ @include margin(0); /** - * Label text should not extend - * beyond the bounds of the textarea. + * Label text should not extend beyond the bounds of the textarea. + * The control excludes the start and end slots, but the notch spans + * the full width of the outline, so their width is added back. */ - max-width: calc( - (100% - var(--padding-start) - var(--padding-end) - #{$textarea-md-floating-label-padding * 2}) / #{$form-control-label-stacked-scale} - ); + max-width: calc((100% + var(--internal-start-end-container-width) - #{$textarea-md-floating-label-padding * 2}) / #{$form-control-label-stacked-scale}); } /** diff --git a/core/src/components/textarea/textarea.scss b/core/src/components/textarea/textarea.scss index 7db3f21df17..84ad244d67f 100644 --- a/core/src/components/textarea/textarea.scss +++ b/core/src/components/textarea/textarea.scss @@ -496,7 +496,11 @@ */ max-width: 200px; - transition: color 150ms cubic-bezier(0.4, 0, 0.2, 1), transform 150ms cubic-bezier(0.4, 0, 0.2, 1); + /** + * The max-width animates with the transform so the label does not + * reveal its full floating width while it is still at full size. + */ + transition: color 150ms #{$form-control-label-transition-easing}, transform 150ms #{$form-control-label-transition-easing}, max-width 150ms #{$form-control-label-transition-easing}; /** * This ensures that double tapping this text @@ -511,8 +515,8 @@ * disable the label transition to prevent the floating label * from animating as its position is adjusted. * - * This class is applied by the start container controller, so - * renaming it here requires renaming it there too. + * This class is applied by the start and end container controller, + * so renaming it here requires renaming it there too. */ :host(.skip-label-transition) .label-text-wrapper { transition: none; diff --git a/core/src/components/textarea/textarea.tsx b/core/src/components/textarea/textarea.tsx index c893a652144..297b5cdebc3 100644 --- a/core/src/components/textarea/textarea.tsx +++ b/core/src/components/textarea/textarea.tsx @@ -14,11 +14,11 @@ import { h, writeTask, } from '@stencil/core'; -import type { ClickController, NotchController, StartContainerController } from '@utils/forms'; +import type { ClickController, NotchController, StartEndContainerController } from '@utils/forms'; import { createClickController, createNotchController, - createStartContainerController, + createStartEndContainerController, checkInvalidState, } from '@utils/forms'; import type { Attributes } from '@utils/helpers'; @@ -68,8 +68,9 @@ export class Textarea implements ComponentInterface { private notchController?: NotchController; private notchSpacerEl: HTMLElement | undefined; - private startContainerController?: StartContainerController; + private startEndContainerController?: StartEndContainerController; private startContainerEl: HTMLElement | undefined; + private endContainerEl: HTMLElement | undefined; private clickController?: ClickController; /** @@ -343,7 +344,7 @@ export class Textarea implements ComponentInterface { const { el } = this; this.slotMutationController = createSlotMutationController(el, ['label', 'start', 'end'], () => { - this.startContainerController?.calculateStartContainerWidth(); + this.startEndContainerController?.calculateContainerWidths(); forceUpdate(this); }); @@ -354,15 +355,16 @@ export class Textarea implements ComponentInterface { () => this.labelSlot ); - this.startContainerController = createStartContainerController( + this.startEndContainerController = createStartEndContainerController( el, () => this.startContainerEl, + () => this.endContainerEl, () => { return Build.isBrowser && getIonMode(this) === 'md' && this.fill === 'outline'; } ); - this.startContainerController.calculateStartContainerWidth(); + this.startEndContainerController.calculateContainerWidths(); this.clickController = createClickController(el, () => this.nativeInput); @@ -415,9 +417,9 @@ export class Textarea implements ComponentInterface { this.notchController = undefined; } - if (this.startContainerController) { - this.startContainerController.destroy(); - this.startContainerController = undefined; + if (this.startEndContainerController) { + this.startEndContainerController.destroy(); + this.startEndContainerController = undefined; } // Clean up validation observer to prevent memory leaks @@ -441,7 +443,7 @@ export class Textarea implements ComponentInterface { componentDidRender() { this.notchController?.calculateNotchWidth(); - this.startContainerController?.calculateStartContainerWidth(); + this.startEndContainerController?.calculateContainerWidths(); } /** @@ -815,7 +817,7 @@ export class Textarea implements ComponentInterface {
-
+
(this.endContainerEl = el)}>
{shouldRenderHighlight &&
} diff --git a/core/src/themes/ionic.globals.scss b/core/src/themes/ionic.globals.scss index 1d5789607f1..ae1e28cb7e7 100644 --- a/core/src/themes/ionic.globals.scss +++ b/core/src/themes/ionic.globals.scss @@ -53,6 +53,10 @@ $form-control-min-width: 2ch; /// iOS does not have a floating label design spec, so we standardize on 0.75. $form-control-label-stacked-scale: 0.75; +// Easing for the color, transform, and max-width transitions of a form +// control label as it moves between resting and floating. +$form-control-label-transition-easing: cubic-bezier(.4, 0, .2, 1); + // Z-Index // -------------------------------------------------- diff --git a/core/src/utils/forms/index.ts b/core/src/utils/forms/index.ts index 31732efac24..ef17d70a583 100644 --- a/core/src/utils/forms/index.ts +++ b/core/src/utils/forms/index.ts @@ -1,5 +1,5 @@ export * from './notch-controller'; -export * from './start-container-controller'; +export * from './start-end-container-controller'; export * from './compare-with-utils'; export * from './item-multiple-inputs'; export * from './click-controller'; diff --git a/core/src/utils/forms/start-container-controller.ts b/core/src/utils/forms/start-container-controller.ts deleted file mode 100644 index 3038f470162..00000000000 --- a/core/src/utils/forms/start-container-controller.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { raf } from '@utils/helpers'; -import { isRTL } from '@utils/rtl'; - -/** - * Applied to the host while the label is being repositioned. The - * component stylesheets style this class, so it must stay in sync with - * the `skip-label-transition` selector in those files. Renaming it here - * requires renaming it there too. - */ -export const SKIP_LABEL_TRANSITION_CLASS = 'skip-label-transition'; - -/** - * Set on the host as an inline style. The component stylesheets declare - * this property and read it with `var()`, so it must stay in sync with - * the `--internal-start-container-adjustment` references in those files. - * Renaming it here requires renaming it there too. - */ -export const START_CONTAINER_ADJUSTMENT_VAR = '--internal-start-container-adjustment'; - -/** - * A utility to measure and update the start container width adjustment - * for form components (input, textarea, select) with `fill="outline"`. - * This adjustment compensates for the start container width when - * positioning floating/stacked labels within the outline border. - * - * Measurement is deferred with raf() to avoid forcing layout during - * component updates, and a ResizeObserver watches for dynamic width - * changes (e.g., when web fonts load or async content changes). - * - * @internal - * @param el - The host element (ion-input, ion-textarea, or ion-select). - * @param getStartContainer - A callback that returns the start container element. - * @param shouldApplyAdjustment - A callback that determines whether the adjustment should be applied. - */ -export const createStartContainerController = ( - el: HTMLElement, - getStartContainer: () => HTMLElement | undefined, - shouldApplyAdjustment: () => boolean -): StartContainerController => { - let resizeObserver: ResizeObserver | undefined; - let observedContainer: HTMLElement | undefined; - let measurementRaf: number | undefined; - let transitionRaf: number | undefined; - let appliedAdjustment: string | undefined; - - const calculateAdjustment = (): string => { - const startSlot = getStartContainer(); - if (!startSlot || !shouldApplyAdjustment()) { - return ''; - } - - /* - * Round to one decimal place so fractional widths produce a stable - * label offset when the start container is added, removed, or resized. - */ - const startContainerWidth = startSlot.getBoundingClientRect().width; - const roundedWidth = Math.round(startContainerWidth * 10) / 10; - const sign = isRTL(el) ? '' : '-'; - return roundedWidth ? `${sign}${roundedWidth}px` : '0px'; - }; - - const scheduleAdjustmentUpdate = () => { - // Return if there is already a measurement queued to avoid a flicker - if (measurementRaf !== undefined) { - return; - } - - /* - * Defer the measurement until the next animation frame so layout - * is measured after the current update has been applied. - */ - measurementRaf = raf(() => { - measurementRaf = undefined; - - const adjustment = calculateAdjustment(); - - // Only suppress the label transition when the label has to move - if (adjustment !== appliedAdjustment) { - // Discard a removal queued by an earlier change - if (transitionRaf !== undefined) { - cancelAnimationFrame(transitionRaf); - } - - el.classList.add(SKIP_LABEL_TRANSITION_CLASS); - el.style.setProperty(START_CONTAINER_ADJUSTMENT_VAR, adjustment); - appliedAdjustment = adjustment; - - // Re-enable the transition once the new offset has been painted - transitionRaf = raf(() => { - transitionRaf = undefined; - el.classList.remove(SKIP_LABEL_TRANSITION_CLASS); - }); - } - - setupResizeObserver(); - }); - }; - - const disconnectResizeObserver = () => { - if (resizeObserver) { - resizeObserver.disconnect(); - resizeObserver = undefined; - observedContainer = undefined; - } - }; - - const setupResizeObserver = () => { - const startSlot = getStartContainer(); - - // Stop watching once the adjustment no longer applies - if (!startSlot || !shouldApplyAdjustment()) { - disconnectResizeObserver(); - return; - } - - // Return when prerendering or already watching this container - if (typeof ResizeObserver !== 'function' || (resizeObserver && observedContainer === startSlot)) { - return; - } - - disconnectResizeObserver(); - - resizeObserver = new ResizeObserver(() => { - scheduleAdjustmentUpdate(); - }); - - observedContainer = startSlot; - resizeObserver.observe(startSlot); - }; - - const calculateStartContainerWidth = () => { - scheduleAdjustmentUpdate(); - }; - - const destroy = () => { - disconnectResizeObserver(); - - if (measurementRaf !== undefined) { - cancelAnimationFrame(measurementRaf); - measurementRaf = undefined; - } - - // Tearing down mid-toggle would leave the transition disabled - if (transitionRaf !== undefined) { - cancelAnimationFrame(transitionRaf); - transitionRaf = undefined; - el.classList.remove(SKIP_LABEL_TRANSITION_CLASS); - } - - // Re-apply from scratch if the element is connected again - appliedAdjustment = undefined; - }; - - return { - calculateStartContainerWidth, - destroy, - }; -}; - -export type StartContainerController = { - calculateStartContainerWidth: () => void; - destroy: () => void; -}; diff --git a/core/src/utils/forms/start-end-container-controller.ts b/core/src/utils/forms/start-end-container-controller.ts new file mode 100644 index 00000000000..9a297dbf388 --- /dev/null +++ b/core/src/utils/forms/start-end-container-controller.ts @@ -0,0 +1,205 @@ +import { raf } from '@utils/helpers'; +import { isRTL } from '@utils/rtl'; + +/** + * Applied to the host while the label is being repositioned. The + * component stylesheets style this class, so it must stay in sync with + * the `skip-label-transition` selector in those files. Renaming it here + * requires renaming it there too. + */ +export const SKIP_LABEL_TRANSITION_CLASS = 'skip-label-transition'; + +/** + * Set on the host as an inline style. The component stylesheets declare + * this property and read it with `var()`, so it must stay in sync with + * the `--internal-start-container-adjustment` references in those files. + * Renaming it here requires renaming it there too. + */ +export const START_CONTAINER_ADJUSTMENT_VAR = '--internal-start-container-adjustment'; + +/** + * Set on the host as an inline style. The component stylesheets declare + * this property and read it with `var()`, so it must stay in sync with + * the `--internal-start-end-container-width` references in those files. + * Renaming it here requires renaming it there too. + */ +export const START_END_CONTAINER_WIDTH_VAR = '--internal-start-end-container-width'; + +/** + * A utility to measure the start and end containers of form components + * (input, textarea, select) with `fill="outline"` and write the results + * to the host. The start container width offsets floating/stacked labels + * so they line up with the outline border. The combined start and end + * container width lets the floating label size itself to the full width + * of the outline while still being positioned relative to the control. + * + * Measurement is deferred with raf() to avoid forcing layout during + * component updates, and a ResizeObserver watches for dynamic width + * changes (e.g., when web fonts load or async content changes). + * + * @internal + * @param el - The host element (ion-input, ion-textarea, or ion-select). + * @param getStartContainer - A callback that returns the start container element. + * @param getEndContainer - A callback that returns the end container element. + * @param shouldMeasure - A callback that determines whether the containers should be measured. + */ +export const createStartEndContainerController = ( + el: HTMLElement, + getStartContainer: () => HTMLElement | undefined, + getEndContainer: () => HTMLElement | undefined, + shouldMeasure: () => boolean +): StartEndContainerController => { + let resizeObserver: ResizeObserver | undefined; + let observedStartContainer: HTMLElement | undefined; + let observedEndContainer: HTMLElement | undefined; + let measurementRaf: number | undefined; + let transitionRaf: number | undefined; + let appliedAdjustment: string | undefined; + let appliedStartEndWidth: string | undefined; + + /* + * Round to one decimal place so fractional widths produce a stable + * label offset and width when a container is added, removed, or resized. + */ + const roundWidth = (width: number) => Math.round(width * 10) / 10; + + const measureContainers = (): { adjustment: string; startEndWidth: string } => { + const startContainer = getStartContainer(); + if (!startContainer || !shouldMeasure()) { + return { adjustment: '', startEndWidth: '' }; + } + + const startContainerWidth = roundWidth(startContainer.getBoundingClientRect().width); + const endContainerWidth = roundWidth(getEndContainer()?.getBoundingClientRect().width ?? 0); + const sign = isRTL(el) ? '' : '-'; + + return { + adjustment: startContainerWidth ? `${sign}${startContainerWidth}px` : '0px', + startEndWidth: `${roundWidth(startContainerWidth + endContainerWidth)}px`, + }; + }; + + const scheduleMeasurement = () => { + // Return if there is already a measurement queued to avoid a flicker + if (measurementRaf !== undefined) { + return; + } + + /* + * Defer the measurement until the next animation frame so layout + * is measured after the current update has been applied. + */ + measurementRaf = raf(() => { + measurementRaf = undefined; + + const { adjustment, startEndWidth } = measureContainers(); + + /* + * The width only changes how much of the label fits, so it can + * animate. The transition is not suppressed here because the end + * container can change size while the label is floating, such as + * when async content loads, and that should not cut the float short. + */ + if (startEndWidth !== appliedStartEndWidth) { + el.style.setProperty(START_END_CONTAINER_WIDTH_VAR, startEndWidth); + appliedStartEndWidth = startEndWidth; + } + + // Only suppress the label transition when the label has to move + if (adjustment !== appliedAdjustment) { + // Discard a removal queued by an earlier change + if (transitionRaf !== undefined) { + cancelAnimationFrame(transitionRaf); + } + + el.classList.add(SKIP_LABEL_TRANSITION_CLASS); + el.style.setProperty(START_CONTAINER_ADJUSTMENT_VAR, adjustment); + appliedAdjustment = adjustment; + + // Re-enable the transition once the new offset has been painted + transitionRaf = raf(() => { + transitionRaf = undefined; + el.classList.remove(SKIP_LABEL_TRANSITION_CLASS); + }); + } + + setupResizeObserver(); + }); + }; + + const disconnectResizeObserver = () => { + if (resizeObserver) { + resizeObserver.disconnect(); + resizeObserver = undefined; + observedStartContainer = undefined; + observedEndContainer = undefined; + } + }; + + const setupResizeObserver = () => { + const startContainer = getStartContainer(); + const endContainer = getEndContainer(); + + // Stop watching once the measurements no longer apply + if (!startContainer || !shouldMeasure()) { + disconnectResizeObserver(); + return; + } + + // Return when prerendering or already watching these containers + if ( + typeof ResizeObserver !== 'function' || + (resizeObserver && observedStartContainer === startContainer && observedEndContainer === endContainer) + ) { + return; + } + + disconnectResizeObserver(); + + resizeObserver = new ResizeObserver(() => { + scheduleMeasurement(); + }); + + observedStartContainer = startContainer; + resizeObserver.observe(startContainer); + + if (endContainer) { + observedEndContainer = endContainer; + resizeObserver.observe(endContainer); + } + }; + + const calculateContainerWidths = () => { + scheduleMeasurement(); + }; + + const destroy = () => { + disconnectResizeObserver(); + + if (measurementRaf !== undefined) { + cancelAnimationFrame(measurementRaf); + measurementRaf = undefined; + } + + // Tearing down mid-toggle would leave the transition disabled + if (transitionRaf !== undefined) { + cancelAnimationFrame(transitionRaf); + transitionRaf = undefined; + el.classList.remove(SKIP_LABEL_TRANSITION_CLASS); + } + + // Re-apply from scratch if the element is connected again + appliedAdjustment = undefined; + appliedStartEndWidth = undefined; + }; + + return { + calculateContainerWidths, + destroy, + }; +}; + +export type StartEndContainerController = { + calculateContainerWidths: () => void; + destroy: () => void; +}; diff --git a/core/src/utils/forms/test/start-container-controller.spec.ts b/core/src/utils/forms/test/start-end-container-controller.spec.ts similarity index 71% rename from core/src/utils/forms/test/start-container-controller.spec.ts rename to core/src/utils/forms/test/start-end-container-controller.spec.ts index e5c474356ba..cbe34c685b9 100644 --- a/core/src/utils/forms/test/start-container-controller.spec.ts +++ b/core/src/utils/forms/test/start-end-container-controller.spec.ts @@ -1,9 +1,10 @@ -import type { StartContainerController } from '../start-container-controller'; +import type { StartEndContainerController } from '../start-end-container-controller'; import { SKIP_LABEL_TRANSITION_CLASS, START_CONTAINER_ADJUSTMENT_VAR, - createStartContainerController, -} from '../start-container-controller'; + START_END_CONTAINER_WIDTH_VAR, + createStartEndContainerController, +} from '../start-end-container-controller'; /** * The controller defers every measurement to an animation frame, so the tests @@ -91,18 +92,25 @@ const restoreGlobals = () => { globalOverrides.clear(); }; -describe('Start Container Controller', () => { +describe('Start and End Container Controller', () => { let host: HTMLElement; let startContainer: HTMLElement; - let controller: StartContainerController; + let endContainer: HTMLElement; + let controller: StartEndContainerController; let shouldApply: boolean; const setStartWidth = (width: number) => { startContainer.getBoundingClientRect = () => ({ width } as DOMRect); }; + const setEndWidth = (width: number) => { + endContainer.getBoundingClientRect = () => ({ width } as DOMRect); + }; + const getAdjustment = () => host.style.getPropertyValue(START_CONTAINER_ADJUSTMENT_VAR); + const getStartEndWidth = () => host.style.getPropertyValue(START_END_CONTAINER_WIDTH_VAR); + const hasSkipClass = () => host.classList.contains(SKIP_LABEL_TRANSITION_CLASS); /** @@ -110,7 +118,7 @@ describe('Start Container Controller', () => { * measurement frame and the follow-up transition frame run. */ const measure = () => { - controller.calculateStartContainerWidth(); + controller.calculateContainerWidths(); flushFrames(); flushFrames(); }; @@ -127,14 +135,18 @@ describe('Start Container Controller', () => { host = document.createElement('div'); startContainer = document.createElement('div'); + endContainer = document.createElement('div'); host.appendChild(startContainer); + host.appendChild(endContainer); document.body.appendChild(host); setStartWidth(32); + setEndWidth(0); - controller = createStartContainerController( + controller = createStartEndContainerController( host, () => startContainer, + () => endContainer, () => shouldApply ); }); @@ -161,7 +173,7 @@ describe('Start Container Controller', () => { }); it('should not measure until the next frame', () => { - controller.calculateStartContainerWidth(); + controller.calculateContainerWidths(); expect(getAdjustment()).toBe(''); }); @@ -173,8 +185,8 @@ describe('Start Container Controller', () => { * the offset into the next frame, leaving one painted frame where the * content has changed but the label has not moved. */ - requestAnimationFrame(() => controller.calculateStartContainerWidth()); - controller.calculateStartContainerWidth(); + requestAnimationFrame(() => controller.calculateContainerWidths()); + controller.calculateContainerWidths(); flushFrames(); @@ -206,11 +218,47 @@ describe('Start Container Controller', () => { expect(getAdjustment()).toBe('32px'); }); + + it('should set the width to the combined start and end container widths', () => { + setEndWidth(48); + + measure(); + + expect(getStartEndWidth()).toBe('80px'); + }); + + it('should not negate the start and end width in rtl', () => { + host.dir = 'rtl'; + setEndWidth(48); + + measure(); + + expect(getStartEndWidth()).toBe('80px'); + }); + + it('should round the combined start and end width to one decimal place', () => { + setStartWidth(32.06); + setEndWidth(48.02); + + measure(); + + expect(getStartEndWidth()).toBe('80.1px'); + }); + + it('should clear the start and end width when it no longer applies', () => { + measure(); + expect(getStartEndWidth()).toBe('32px'); + + shouldApply = false; + measure(); + + expect(getStartEndWidth()).toBe(''); + }); }); describe('label transition', () => { it('should suppress the transition for one frame when the offset changes', () => { - controller.calculateStartContainerWidth(); + controller.calculateContainerWidths(); flushFrames(); expect(hasSkipClass()).toBe(true); @@ -222,14 +270,29 @@ describe('Start Container Controller', () => { it('should not suppress the transition when the offset is unchanged', () => { measure(); - controller.calculateStartContainerWidth(); + controller.calculateContainerWidths(); flushFrames(); expect(hasSkipClass()).toBe(false); }); + it('should not suppress the transition when only the end container changes', () => { + /** + * The end container can resize while the label is floating. Cutting + * the transition short would snap the label into place. + */ + measure(); + + setEndWidth(48); + controller.calculateContainerWidths(); + flushFrames(); + + expect(getStartEndWidth()).toBe('80px'); + expect(hasSkipClass()).toBe(false); + }); + it('should keep the transition suppressed while consecutive changes are applied', () => { - controller.calculateStartContainerWidth(); + controller.calculateContainerWidths(); flushFrames(); /** @@ -237,7 +300,7 @@ describe('Start Container Controller', () => { * frame that applies the second one. */ setStartWidth(64); - controller.calculateStartContainerWidth(); + controller.calculateContainerWidths(); flushFrames(); expect(getAdjustment()).toBe('-64px'); @@ -246,18 +309,18 @@ describe('Start Container Controller', () => { }); describe('resize observer', () => { - it('should observe the start container after measuring', () => { + it('should observe the start and end containers after measuring', () => { measure(); - expect(activeObserver()?.observed).toEqual([startContainer]); + expect(activeObserver()?.observed).toEqual([startContainer, endContainer]); }); - it('should not rebind the observer when the container is unchanged', () => { + it('should not rebind the observer when the containers are unchanged', () => { measure(); measure(); expect(MockResizeObserver.instances).toHaveLength(1); - expect(activeObserver()?.observed).toEqual([startContainer]); + expect(activeObserver()?.observed).toEqual([startContainer, endContainer]); }); it('should rebind when the start container is replaced', () => { @@ -269,7 +332,20 @@ describe('Start Container Controller', () => { measure(); expect(firstObserver?.disconnected).toBe(true); - expect(activeObserver()?.observed).toEqual([startContainer]); + expect(activeObserver()?.observed).toEqual([startContainer, endContainer]); + }); + + it('should rebind when the end container is replaced', () => { + measure(); + const firstObserver = activeObserver(); + + endContainer = document.createElement('div'); + setEndWidth(48); + measure(); + + expect(firstObserver?.disconnected).toBe(true); + expect(activeObserver()?.observed).toEqual([startContainer, endContainer]); + expect(getStartEndWidth()).toBe('80px'); }); it('should re-measure when the container resizes', () => { @@ -282,6 +358,16 @@ describe('Start Container Controller', () => { expect(getAdjustment()).toBe('-64px'); }); + it('should re-measure when the end container resizes', () => { + measure(); + + setEndWidth(48); + activeObserver()?.callback(); + flushFrames(); + + expect(getStartEndWidth()).toBe('80px'); + }); + it('should disconnect when the adjustment stops applying', () => { measure(); const observer = activeObserver(); @@ -302,7 +388,7 @@ describe('Start Container Controller', () => { measure(); expect(getAdjustment()).toBe('-32px'); - expect(activeObserver()?.observed).toEqual([startContainer]); + expect(activeObserver()?.observed).toEqual([startContainer, endContainer]); }); }); @@ -317,7 +403,7 @@ describe('Start Container Controller', () => { }); it('should not measure after a pending frame is cancelled', () => { - controller.calculateStartContainerWidth(); + controller.calculateContainerWidths(); controller.destroy(); flushFrames(); @@ -326,7 +412,7 @@ describe('Start Container Controller', () => { }); it('should not leave the transition suppressed when torn down mid-toggle', () => { - controller.calculateStartContainerWidth(); + controller.calculateContainerWidths(); // The class is applied, but the frame that removes it has not run yet flushFrames(); @@ -341,15 +427,16 @@ describe('Start Container Controller', () => { measure(); controller.destroy(); - controller = createStartContainerController( + controller = createStartEndContainerController( host, () => startContainer, + () => endContainer, () => shouldApply ); measure(); expect(getAdjustment()).toBe('-32px'); - expect(activeObserver()?.observed).toEqual([startContainer]); + expect(activeObserver()?.observed).toEqual([startContainer, endContainer]); }); }); }); From 5f3f5fcae2bc83cb4301bc6e47c7e0b64f6d3d76 Mon Sep 17 00:00:00 2001 From: Shane Date: Thu, 15 Oct 2026 16:02:01 +0000 Subject: [PATCH 02/12] fix(nav): defer non-animated swipe back until the gesture completes (#31552) Issue number: resolves #29793 --------- ## What is the current behavior? Currently, swipe to go back on `ion-nav` and `ion-router-outlet` starts the back transition as soon as the gesture starts. With animations disabled, through either the `animated` prop or the global config, there's no progress animation for the swipe to drive, so the transition completes instantly. The previous page gets its enter lifecycle events even when the swipe is abandoned, and a completed swipe never navigates. ## What is the new behavior? With animations disabled, the swipe now waits until it's released to decide whether to go back. A completed swipe goes back without an animation and an abandoned one does nothing. For `ion-router-outlet`, core calls the swipe handler's `onStart` and `onEnd(true)` on release, so Angular, React and Vue didn't need changes. The host also ignores pointer events during the swipe, the same way a swipe-driven transition does, so releasing doesn't click whatever is under the pointer. ## Does this introduce a breaking change? - [ ] Yes - [X] No ## Other information [Relevant test screen (Angular)](https://ionic-framework-git-fw-7757-ionic1.vercel.app/angular/standalone/swipe-animated-disabled?ionic:mode=ios) ## Current dev build ``` 9.0.7-dev.11791478996.1b854592 ``` --- core/src/components/nav/nav.tsx | 32 +++- .../components/nav/test/swipe-back/nav.e2e.ts | 153 ++++++++++++++++++ .../router-outlet/router-outlet.tsx | 36 ++++- .../test/swipe-back/router-outlet.e2e.ts | 43 +++++ core/src/utils/transition/index.ts | 13 ++ .../swipe-animated-disabled.spec.ts | 123 ++++++++++++++ .../standalone/app-standalone/app.routes.ts | 8 + .../home-page/home-page.component.html | 5 + ...ipe-animated-disabled-details.component.ts | 14 ++ .../swipe-animated-disabled-main.component.ts | 30 ++++ .../swipe-animated-disabled.component.ts | 29 ++++ .../src/ReactRouter/StackManager.tsx | 8 +- .../e2e/playwright/swipe-to-go-back.spec.ts | 48 +++++- .../tests/e2e/playwright/swipe-back.spec.ts | 41 +++++ 14 files changed, 573 insertions(+), 10 deletions(-) create mode 100644 core/src/components/nav/test/swipe-back/nav.e2e.ts create mode 100644 core/src/components/router-outlet/test/swipe-back/router-outlet.e2e.ts create mode 100644 packages/angular/test/base/e2e/src/standalone/swipe-animated-disabled.spec.ts create mode 100644 packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled-details.component.ts create mode 100644 packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled-main.component.ts create mode 100644 packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled.component.ts create mode 100644 packages/vue/test/base/tests/e2e/playwright/swipe-back.spec.ts diff --git a/core/src/components/nav/nav.tsx b/core/src/components/nav/nav.tsx index 2892d7030dc..20aacd6d763 100644 --- a/core/src/components/nav/nav.tsx +++ b/core/src/components/nav/nav.tsx @@ -4,7 +4,7 @@ import { getTimeGivenProgression } from '@utils/animation/cubic-bezier'; import { assert } from '@utils/helpers'; import { printIonWarning } from '@utils/logging'; import type { TransitionOptions } from '@utils/transition'; -import { lifecycle, setPageHidden, transition } from '@utils/transition'; +import { blockSwipeClicks, lifecycle, setPageHidden, transition } from '@utils/transition'; import { config } from '../../global/config'; import { getIonMode } from '../../global/ionic-global'; @@ -30,6 +30,7 @@ import { VIEW_STATE_ATTACHED, VIEW_STATE_DESTROYED, VIEW_STATE_NEW, convertToVie export class Nav implements ComponentInterface { private transInstr: TransitionInstruction[] = []; private sbAni?: Animation; + private unblockSwipeClicks?: () => void; private gestureOrAnimationInProgress = false; private isTransitioning = false; private destroyed = false; @@ -145,6 +146,14 @@ export class Nav implements ComponentInterface { this.gesture.destroy(); this.gesture = undefined; } + + // Destroying the gesture skips onEnd, so end a deferred swipe here. + if (this.unblockSwipeClicks) { + this.unblockSwipeClicks(); + this.unblockSwipeClicks = undefined; + this.gestureOrAnimationInProgress = false; + } + this.transInstr.length = 0; this.views.length = 0; this.destroyed = true; @@ -919,6 +928,16 @@ export class Nav implements ComponentInterface { private onStart() { this.gestureOrAnimationInProgress = true; + + /** + * Without animations there's nothing for the swipe to drive, so + * wait until the gesture ends to decide whether to go back. + */ + if (!(this.animated && config.getBoolean('animated', true))) { + this.unblockSwipeClicks = blockSwipeClicks(this.el); + return; + } + this.pop({ direction: 'back', progressAnimation: true }); } @@ -958,6 +977,17 @@ export class Nav implements ComponentInterface { this.sbAni.progressEnd(shouldComplete ? 1 : 0, newStepValue, dur); } else { this.gestureOrAnimationInProgress = false; + + const { unblockSwipeClicks } = this; + if (unblockSwipeClicks) { + this.unblockSwipeClicks = undefined; + + if (shouldComplete) { + this.pop({ direction: 'back' }); + } + + unblockSwipeClicks(); + } } } diff --git a/core/src/components/nav/test/swipe-back/nav.e2e.ts b/core/src/components/nav/test/swipe-back/nav.e2e.ts new file mode 100644 index 00000000000..ebcb44e934c --- /dev/null +++ b/core/src/components/nav/test/swipe-back/nav.e2e.ts @@ -0,0 +1,153 @@ +import { expect } from '@playwright/test'; +import type { E2EPage, E2EPageOptions } from '@utils/test/playwright'; +import { configs, dragElementBy, test } from '@utils/test/playwright'; + +/** + * Renders an `ion-nav` and pushes a second page. The `extraScript` + * runs before Ionic loads, so it can change the global config. + */ +const pushSecondPage = async (page: E2EPage, config: E2EPageOptions, navAttrs = '', extraScript = '') => { + await page.setContent( + ` + + + + + `, + config + ); + + const nav = page.locator('ion-nav'); + await nav.evaluate((el: HTMLIonNavElement) => el.push((window as any).createPage('page-two'))); + await expect(page.locator('#page-two')).toBeVisible(); + + return nav; +}; + +/** + * Starts a swipe to go back and lets go partway across, so the user + * should stay on the current page. + */ +const abandonSwipeBack = async (page: E2EPage) => { + const nav = page.locator('ion-nav'); + const box = (await nav.boundingBox())!; + const startX = box.x + 5; + const y = box.y + box.height / 2; + + await dragElementBy(nav, page, 60, 0, startX, y, false); + + /** + * A quick drag is released with enough velocity to complete + * the swipe, so slow down before letting go. + */ + for (let i = 1; i <= 3; i++) { + await page.mouse.move(startX + 60 + i, y); + await page.waitForTimeout(50); + } + + await page.mouse.up(); + await page.waitForChanges(); +}; + +/** + * Swipe to go back is only enabled by default in iOS mode, + * and this behavior does not vary across directions. + */ +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('nav: swipe back'), () => { + test('should not pop the page when an abandoned swipe starts on a non-animated nav', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29793', + }); + + const nav = await pushSecondPage(page, config, 'animated="false"'); + + await abandonSwipeBack(page); + + await expect(page.locator('#page-two')).toBeVisible(); + expect(await nav.evaluate((el: HTMLIonNavElement) => el.getLength())).toBe(2); + }); + + test('should not pop the page when an abandoned swipe starts with animations disabled globally', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29793', + }); + + const nav = await pushSecondPage(page, config, '', 'window.Ionic.config.animated = false;'); + + await abandonSwipeBack(page); + + await expect(page.locator('#page-two')).toBeVisible(); + expect(await nav.evaluate((el: HTMLIonNavElement) => el.getLength())).toBe(2); + }); + + test('should not click the page when an abandoned swipe is released on a non-animated nav', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29793', + }); + + await pushSecondPage(page, config, 'animated="false"'); + + await page.locator('#page-two').evaluate((pageTwo) => { + const button = document.createElement('button'); + button.style.cssText = 'position: absolute; inset: 0; width: 100%; height: 100%;'; + button.addEventListener('click', () => ((window as any).pageClicked = true)); + pageTwo.appendChild(button); + }); + + await abandonSwipeBack(page); + + expect(await page.evaluate(() => (window as any).pageClicked)).toBeUndefined(); + }); + + test('should not block clicks on a non-animated nav that is moved during a swipe', async ({ page }) => { + const nav = await pushSecondPage(page, config, 'animated="false"'); + const box = (await nav.boundingBox())!; + + await dragElementBy(nav, page, 60, 0, box.x + 5, box.y + box.height / 2, false); + await expect(nav).toHaveCSS('pointer-events', 'none'); + + await nav.evaluate((el) => document.body.appendChild(el)); + + await expect(nav).not.toHaveCSS('pointer-events', 'none'); + await page.mouse.up(); + }); + + test('should pop the page when a swipe completes on a non-animated nav', async ({ page }) => { + const nav = await pushSecondPage(page, config, 'animated="false"'); + const box = (await nav.boundingBox())!; + + await dragElementBy(nav, page, box.width * 0.75, 0, box.x + 5); + + await expect.poll(() => nav.evaluate((el: HTMLIonNavElement) => el.getLength())).toBe(1); + await expect(page.locator('#page-one')).toBeVisible(); + }); + + test('should pop the page when a swipe completes on an animated nav', async ({ page }) => { + const nav = await pushSecondPage(page, config); + const box = (await nav.boundingBox())!; + + await dragElementBy(nav, page, box.width * 0.75, 0, box.x + 5); + + await expect.poll(() => nav.evaluate((el: HTMLIonNavElement) => el.getLength())).toBe(1); + await expect(page.locator('#page-one')).toBeVisible(); + }); + }); +}); diff --git a/core/src/components/router-outlet/router-outlet.tsx b/core/src/components/router-outlet/router-outlet.tsx index 1163a4bd4c4..7f1bceb7e7c 100644 --- a/core/src/components/router-outlet/router-outlet.tsx +++ b/core/src/components/router-outlet/router-outlet.tsx @@ -5,7 +5,7 @@ import { attachComponent, detachComponent } from '@utils/framework-delegate'; import { shallowEqualStringMap, hasLazyBuild } from '@utils/helpers'; import { createLockController } from '@utils/lock-controller'; import { printIonError } from '@utils/logging'; -import { transition } from '@utils/transition'; +import { blockSwipeClicks, transition } from '@utils/transition'; import { config } from '../../global/config'; import { getIonMode } from '../../global/ionic-global'; @@ -33,6 +33,7 @@ export class RouterOutlet implements ComponentInterface, NavOutlet { private activeParams: any; private gesture?: Gesture; private ani?: Animation; + private unblockSwipeClicks?: () => void; private gestureOrAnimationInProgress = false; @Element() el!: HTMLElement; @@ -82,6 +83,16 @@ export class RouterOutlet implements ComponentInterface, NavOutlet { async connectedCallback() { const onStart = () => { this.gestureOrAnimationInProgress = true; + + /** + * Without animations there's nothing for the swipe to drive, so + * wait until the gesture ends to decide whether to go back. + */ + if (!(this.animated && config.getBoolean('animated', true))) { + this.unblockSwipeClicks = blockSwipeClicks(this.el); + return; + } + if (this.swipeHandler) { this.swipeHandler.onStart(); } @@ -125,7 +136,21 @@ export class RouterOutlet implements ComponentInterface, NavOutlet { this.ani.progressEnd(shouldComplete ? 1 : 0, newStepValue, dur); } else { - this.gestureOrAnimationInProgress = false; + const { swipeHandler, unblockSwipeClicks: unblock } = this; + this.unblockSwipeClicks = undefined; + + // The stack can change during the swipe, so check that going back is still possible. + if (unblock && shouldComplete && swipeHandler?.canStart()) { + new Promise((resolve) => resolve(swipeHandler.onStart())) + .then(() => swipeHandler.onEnd(true)) + .finally(() => { + this.gestureOrAnimationInProgress = false; + unblock(); + }); + } else { + this.gestureOrAnimationInProgress = false; + unblock?.(); + } } } ); @@ -152,6 +177,13 @@ export class RouterOutlet implements ComponentInterface, NavOutlet { this.gesture.destroy(); this.gesture = undefined; } + + // Destroying the gesture skips onEnd, so end a deferred swipe here. + if (this.unblockSwipeClicks) { + this.unblockSwipeClicks(); + this.unblockSwipeClicks = undefined; + this.gestureOrAnimationInProgress = false; + } } /** @internal */ diff --git a/core/src/components/router-outlet/test/swipe-back/router-outlet.e2e.ts b/core/src/components/router-outlet/test/swipe-back/router-outlet.e2e.ts new file mode 100644 index 00000000000..a5aac151ec1 --- /dev/null +++ b/core/src/components/router-outlet/test/swipe-back/router-outlet.e2e.ts @@ -0,0 +1,43 @@ +import { expect } from '@playwright/test'; +import { configs, dragElementBy, test } from '@utils/test/playwright'; + +/** + * Swipe to go back is only enabled by default in iOS mode, + * and this behavior does not vary across directions. + */ +configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => { + test.describe(title('router-outlet: swipe back'), () => { + test('should not block clicks on a non-animated outlet that is moved during a swipe', async ({ page }) => { + await page.setContent( + ` + + + + `, + config + ); + + const outlet = page.locator('ion-router-outlet'); + + // A framework normally provides the handler that lets the gesture start. + await outlet.evaluate((el: HTMLIonRouterOutletElement) => { + el.swipeHandler = { + canStart: () => true, + onStart: () => {}, + onEnd: () => {}, + }; + }); + await page.waitForChanges(); + + const box = (await outlet.boundingBox())!; + + await dragElementBy(outlet, page, 60, 0, box.x + 5, box.y + box.height / 2, false); + await expect(outlet).toHaveCSS('pointer-events', 'none'); + + await outlet.evaluate((el) => document.body.appendChild(el)); + + await expect(outlet).not.toHaveCSS('pointer-events', 'none'); + await page.mouse.up(); + }); + }); +}); diff --git a/core/src/utils/transition/index.ts b/core/src/utils/transition/index.ts index e74e7318c82..e9d6598a6e9 100644 --- a/core/src/utils/transition/index.ts +++ b/core/src/utils/transition/index.ts @@ -282,6 +282,19 @@ export const setPageHidden = (el: HTMLElement, hidden: boolean) => { } }; +/** + * Blocks clicks on `el` during a swipe to go back without animations, + * so releasing the pointer doesn't click whatever is under it. Returns + * a function that unblocks it after the release click. + */ +export const blockSwipeClicks = (el: HTMLElement) => { + el.style.setProperty('pointer-events', 'none'); + + return () => { + setTimeout(() => el.style.removeProperty('pointer-events')); + }; +}; + const setZIndex = ( enteringEl: HTMLElement | undefined, leavingEl: HTMLElement | undefined, diff --git a/packages/angular/test/base/e2e/src/standalone/swipe-animated-disabled.spec.ts b/packages/angular/test/base/e2e/src/standalone/swipe-animated-disabled.spec.ts new file mode 100644 index 00000000000..dd2365ae92b --- /dev/null +++ b/packages/angular/test/base/e2e/src/standalone/swipe-animated-disabled.spec.ts @@ -0,0 +1,123 @@ +import { expect, test, type Page } from '@playwright/test'; +import { ionSwipeToGoBack } from '../../utils/drag-utils'; +import { ionPageVisible, ionPageHidden, ionPageDoesNotExist } from '../../utils/test-utils'; + +/** + * Starts a swipe to go back and lets go partway across, so the user + * should stay on the current page. + */ +const abandonSwipeBack = async (page: Page) => { + const viewport = page.viewportSize()!; + const y = viewport.height / 2; + + await page.mouse.move(5, y); + await page.mouse.down(); + await page.mouse.move(35, y, { steps: 5 }); + await page.mouse.move(65, y, { steps: 5 }); + + // A quick drag is released with enough velocity to complete the swipe, so slow down before letting go. + for (let i = 1; i <= 3; i++) { + await page.mouse.move(65 + i, y); + await page.waitForTimeout(50); + } + + await page.mouse.up(); + + // Let a back transition started by the swipe settle before asserting. + await page.waitForTimeout(500); +}; + +test.describe('Swipe Animated Disabled', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/standalone/swipe-animated-disabled?ionic:mode=ios'); + }); + + test('should not fire enter lifecycle events on the previous page when an abandoned swipe starts on a non-animated outlet', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29793', + }); + + await ionPageVisible(page, 'app-swipe-animated-disabled-main'); + await expect(page.locator('#ionViewWillEnter')).toHaveText('1'); + await expect(page.locator('#ionViewDidEnter')).toHaveText('1'); + + await page.locator('#swipe-animated-disabled-details').click(); + await ionPageVisible(page, 'app-swipe-animated-disabled-details'); + await ionPageHidden(page, 'app-swipe-animated-disabled-main'); + + await abandonSwipeBack(page); + + await ionPageVisible(page, 'app-swipe-animated-disabled-details'); + await ionPageHidden(page, 'app-swipe-animated-disabled-main'); + await expect(page.locator('#ionViewWillEnter')).toHaveText('1'); + await expect(page.locator('#ionViewDidEnter')).toHaveText('1'); + }); + + test('should go back when a swipe completes on a non-animated outlet', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29793', + }); + + await ionPageVisible(page, 'app-swipe-animated-disabled-main'); + + await page.locator('#swipe-animated-disabled-details').click(); + await ionPageVisible(page, 'app-swipe-animated-disabled-details'); + await ionPageHidden(page, 'app-swipe-animated-disabled-main'); + + await ionSwipeToGoBack(page, true); + + await ionPageVisible(page, 'app-swipe-animated-disabled-main'); + await ionPageDoesNotExist(page, 'app-swipe-animated-disabled-details'); + await expect(page.locator('#ionViewWillEnter')).toHaveText('2'); + await expect(page.locator('#ionViewDidEnter')).toHaveText('2'); + }); + + test('should swipe back once the outlet becomes animated', async ({ page }) => { + await ionPageVisible(page, 'app-swipe-animated-disabled-main'); + + await page.locator('#toggle-animated').click(); + await expect(page.locator('#animated-value')).toHaveText('true'); + + await page.locator('#swipe-animated-disabled-details').click(); + await ionPageVisible(page, 'app-swipe-animated-disabled-details'); + await ionPageHidden(page, 'app-swipe-animated-disabled-main'); + + await ionSwipeToGoBack(page, true); + + await ionPageVisible(page, 'app-swipe-animated-disabled-main'); + await ionPageDoesNotExist(page, 'app-swipe-animated-disabled-details'); + }); +}); + +test.describe('Swipe Animated Disabled: global config', () => { + test.beforeEach(async ({ page }) => { + // Testing mode sets the global `animated` config to false. + await page.goto('/standalone/swipe-animated-disabled?ionic:mode=ios&ionic:_testing=true'); + }); + + test('should not fire enter lifecycle events on the previous page when an abandoned swipe starts with animations disabled globally', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29793', + }); + + await ionPageVisible(page, 'app-swipe-animated-disabled-main'); + + // Animate the outlet so the global config is the only thing disabling animations. + await page.locator('#toggle-animated').click(); + await expect(page.locator('#animated-value')).toHaveText('true'); + + await page.locator('#swipe-animated-disabled-details').click(); + await ionPageVisible(page, 'app-swipe-animated-disabled-details'); + await ionPageHidden(page, 'app-swipe-animated-disabled-main'); + + await abandonSwipeBack(page); + + await ionPageVisible(page, 'app-swipe-animated-disabled-details'); + await ionPageHidden(page, 'app-swipe-animated-disabled-main'); + await expect(page.locator('#ionViewWillEnter')).toHaveText('1'); + await expect(page.locator('#ionViewDidEnter')).toHaveText('1'); + }); +}); diff --git a/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts b/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts index 1e9349293fc..37a2bae6314 100644 --- a/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts +++ b/packages/angular/test/base/src/app/standalone/app-standalone/app.routes.ts @@ -109,6 +109,14 @@ export const routes: Routes = [ { path: 'details', loadComponent: () => import('../swipe-gesture-disabled/swipe-gesture-disabled-details.component').then(c => c.SwipeGestureDisabledDetailsComponent) } ] }, + { + path: 'swipe-animated-disabled', + loadComponent: () => import('../swipe-animated-disabled/swipe-animated-disabled.component').then(c => c.SwipeAnimatedDisabledComponent), + children: [ + { path: '', loadComponent: () => import('../swipe-animated-disabled/swipe-animated-disabled-main.component').then(c => c.SwipeAnimatedDisabledMainComponent) }, + { path: 'details', loadComponent: () => import('../swipe-animated-disabled/swipe-animated-disabled-details.component').then(c => c.SwipeAnimatedDisabledDetailsComponent) } + ] + }, { path: 'validation', children: [ diff --git a/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html b/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html index 738a3f335ca..9c48d60f5fb 100644 --- a/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html +++ b/packages/angular/test/base/src/app/standalone/home-page/home-page.component.html @@ -104,6 +104,11 @@ Swipe Gesture Disabled Test + + + Swipe Animated Disabled Test + + Tabs Search Params Test diff --git a/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled-details.component.ts b/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled-details.component.ts new file mode 100644 index 00000000000..668487e26eb --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled-details.component.ts @@ -0,0 +1,14 @@ +import { Component } from '@angular/core'; +import { IonContent } from '@ionic/angular'; + +@Component({ + selector: 'app-swipe-animated-disabled-details', + template: ` + +
Details (animated disabled)
+
+ `, + standalone: true, + imports: [IonContent] +}) +export class SwipeAnimatedDisabledDetailsComponent {} diff --git a/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled-main.component.ts b/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled-main.component.ts new file mode 100644 index 00000000000..06d030fb709 --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled-main.component.ts @@ -0,0 +1,30 @@ +import { Component, signal } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { IonContent, IonItem, IonLabel, IonRouterLink } from '@ionic/angular'; + +@Component({ + selector: 'app-swipe-animated-disabled-main', + template: ` + + + Details + +

ionViewWillEnter: {{ willEnter() }}

+

ionViewDidEnter: {{ didEnter() }}

+
+ `, + standalone: true, + imports: [IonContent, IonItem, IonLabel, IonRouterLink, RouterModule] +}) +export class SwipeAnimatedDisabledMainComponent { + willEnter = signal(0); + didEnter = signal(0); + + ionViewWillEnter() { + this.willEnter.update((n) => n + 1); + } + + ionViewDidEnter() { + this.didEnter.update((n) => n + 1); + } +} diff --git a/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled.component.ts b/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled.component.ts new file mode 100644 index 00000000000..1c00d0444bb --- /dev/null +++ b/packages/angular/test/base/src/app/standalone/swipe-animated-disabled/swipe-animated-disabled.component.ts @@ -0,0 +1,29 @@ +import { Component, signal } from '@angular/core'; +import { IonBackButton, IonButton, IonButtons, IonContent, IonHeader, IonRouterOutlet, IonTitle, IonToolbar } from '@ionic/angular'; + +@Component({ + selector: 'app-swipe-animated-disabled', + template: ` + + + + + + Swipe Animated Disabled + + + animated: {{ animated() }} + + + + + + + + `, + standalone: true, + imports: [IonBackButton, IonButton, IonButtons, IonContent, IonHeader, IonRouterOutlet, IonTitle, IonToolbar] +}) +export class SwipeAnimatedDisabledComponent { + animated = signal(false); +} diff --git a/packages/react-router/src/ReactRouter/StackManager.tsx b/packages/react-router/src/ReactRouter/StackManager.tsx index 6848c4941bb..456f14f2b8c 100644 --- a/packages/react-router/src/ReactRouter/StackManager.tsx +++ b/packages/react-router/src/ReactRouter/StackManager.tsx @@ -1460,7 +1460,7 @@ export class StackManager extends React.PureComponent { }; const onStart = async () => { - // Core only calls onEnd when a progress animation exists, so with animations off a + // Core skips onEnd when an animated swipe ends without a progress animation, so a // previous gesture can leave its mark behind. Drop any stale one before marking. this.clearSwipeRevealedView(); @@ -1494,9 +1494,9 @@ export class StackManager extends React.PureComponent { // When the gesture starts, kick off a transition controlled via swipe gesture if (enteringViewItem && leavingViewItem) { - // Without a leaving view there is no progress animation, and core only calls - // swipeHandler.onEnd when one exists, so nothing would clear the mark until the - // next transition. + // Without a leaving view there is no progress animation, and core skips + // swipeHandler.onEnd for an animated swipe without one, so nothing would clear the + // mark until the next transition. markSwipeRevealed(enteringViewItem); this.swipeRevealedViewItem = enteringViewItem; diff --git a/packages/react-router/test/base/tests/e2e/playwright/swipe-to-go-back.spec.ts b/packages/react-router/test/base/tests/e2e/playwright/swipe-to-go-back.spec.ts index f9539df0e94..8817177ce4d 100644 --- a/packages/react-router/test/base/tests/e2e/playwright/swipe-to-go-back.spec.ts +++ b/packages/react-router/test/base/tests/e2e/playwright/swipe-to-go-back.spec.ts @@ -1,13 +1,12 @@ import { test, expect } from '@playwright/test'; -import { ionPageVisible, ionPageHidden, ionPageDoesNotExist, ionNav } from './utils/test-utils'; +import { ionPageVisible, ionPageHidden, ionPageDoesNotExist, ionNav, withTestingMode } from './utils/test-utils'; import { ionSwipeToGoBack } from './utils/drag-utils'; const IOS_MODE = 'ionic:mode=ios'; test.describe('Swipe To Go Back', () => { /* - This spec tests that swipe to go back works. - Animations must be enabled (no ionic:_testing=true) for gesture tests. + This spec tests that swipe to go back works with animations enabled (no ionic:_testing=true). */ test('should swipe and abort', async ({ page }) => { @@ -218,3 +217,46 @@ test.describe('Swipe To Go Back', () => { await ionPageHidden(page, 'disabled-main'); }); }); + +test.describe('Swipe To Go Back without animations', () => { + /* + Testing mode disables animations, so these cover swipe to go back without them. + */ + + test('should swipe and abort', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29793', + }); + + await page.goto(withTestingMode(`/swipe-to-go-back?${IOS_MODE}`)); + await ionPageVisible(page, 'main'); + + await ionNav(page, 'ion-item', 'Details'); + await ionPageVisible(page, 'details'); + await ionPageHidden(page, 'main'); + + await ionSwipeToGoBack(page, false, 'ion-router-outlet#swipe-to-go-back'); + await ionPageVisible(page, 'details'); + await ionPageHidden(page, 'main'); + await expect(page).toHaveURL(/\/swipe-to-go-back\/details/); + }); + + test('should swipe and go back', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29793', + }); + + await page.goto(withTestingMode(`/swipe-to-go-back?${IOS_MODE}`)); + await ionPageVisible(page, 'main'); + + await ionNav(page, 'ion-item', 'Details'); + await ionPageVisible(page, 'details'); + await ionPageHidden(page, 'main'); + + await ionSwipeToGoBack(page, true, 'ion-router-outlet#swipe-to-go-back'); + await ionPageVisible(page, 'main'); + await expect(page).toHaveURL(/\/swipe-to-go-back(\?|$)/); + }); +}); diff --git a/packages/vue/test/base/tests/e2e/playwright/swipe-back.spec.ts b/packages/vue/test/base/tests/e2e/playwright/swipe-back.spec.ts new file mode 100644 index 00000000000..3c78fa49149 --- /dev/null +++ b/packages/vue/test/base/tests/e2e/playwright/swipe-back.spec.ts @@ -0,0 +1,41 @@ +import { test, expect } from './utils/test-base'; +import { ionPageVisible, ionPageHidden, ionSwipeToGoBack } from './utils/test-utils'; + +/** + * The test fixture disables animations, so these cover swipe to go back without them. + */ +test.describe('Swipe to go back without animations', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/?ionic:mode=ios'); + await ionPageVisible(page, 'home'); + + await page.locator('ion-item#routing').click({ force: true }); + await ionPageVisible(page, 'routing'); + await ionPageHidden(page, 'home'); + }); + + test('should go back when the swipe completes', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29793', + }); + + await ionSwipeToGoBack(page, true); + + await ionPageVisible(page, 'home'); + await expect(page).toHaveURL(/\/\?/); + }); + + test('should stay on the current page when the swipe is abandoned', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/29793', + }); + + await ionSwipeToGoBack(page, false); + + await ionPageVisible(page, 'routing'); + await ionPageHidden(page, 'home'); + await expect(page).toHaveURL(/\/routing/); + }); +}); From d1294c356755da5b6c57ff5c9c55fde688fb78be Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:19:08 +0000 Subject: [PATCH 03/12] chore(deps): update stencil-output-targets (#31445) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [@stencil/angular-output-target](https://stenciljs.com/) ([source](https://redirect.github.com/stenciljs/output-targets)) | [`1.4.1` → `1.5.2`](https://renovatebot.com/diffs/npm/@stencil%2fangular-output-target/1.4.1/1.5.2) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@stencil%2fangular-output-target/1.5.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@stencil%2fangular-output-target/1.4.1/1.5.2?slim=true) | | [@stencil/vue-output-target](https://stenciljs.com/) ([source](https://redirect.github.com/stenciljs/output-targets)) | [`0.14.2` → `0.14.6`](https://renovatebot.com/diffs/npm/@stencil%2fvue-output-target/0.14.2/0.14.6) | ![age](https://developer.mend.io/api/mc/badges/age/npm/@stencil%2fvue-output-target/0.14.6?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/@stencil%2fvue-output-target/0.14.2/0.14.6?slim=true) | --- ### Release Notes
stenciljs/output-targets (@​stencil/angular-output-target) ### [`v1.5.2`](https://redirect.github.com/stenciljs/output-targets/compare/@stencil/angular-output-target@1.5.1...@stencil/angular-output-target@1.5.2) [Compare Source](https://redirect.github.com/stenciljs/output-targets/compare/@stencil/angular-output-target@1.5.1...@stencil/angular-output-target@1.5.2) ### [`v1.5.1`](https://redirect.github.com/stenciljs/output-targets/compare/@stencil/angular-output-target@1.5.0...@stencil/angular-output-target@1.5.1) [Compare Source](https://redirect.github.com/stenciljs/output-targets/compare/@stencil/angular-output-target@1.5.0...@stencil/angular-output-target@1.5.1) ### [`v1.5.0`](https://redirect.github.com/stenciljs/output-targets/compare/@stencil/angular-output-target@1.4.1...@stencil/angular-output-target@1.5.0) [Compare Source](https://redirect.github.com/stenciljs/output-targets/compare/@stencil/angular-output-target@1.4.1...@stencil/angular-output-target@1.5.0)
--- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - "every weekday before 11am" - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Never, or you tick the rebase/retry checkbox. 👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://redirect.github.com/renovatebot/renovate/discussions) if that's undesired. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/ionic-team/ionic-framework). --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Brandy Smith <6577830+brandyscarney@users.noreply.github.com> --- core/package-lock.json | 18 ++++---- core/package.json | 2 +- packages/angular/.prettierignore | 1 + packages/angular/eslint.config.js | 11 ++++- .../boolean-attribute.ts | 43 +++++++++++++++++++ .../boolean-attribute.ts | 43 +++++++++++++++++++ 6 files changed, 107 insertions(+), 11 deletions(-) create mode 100644 packages/angular/src/lazy/directives/angular-component-lib/boolean-attribute.ts create mode 100644 packages/angular/src/standalone/directives/angular-component-lib/boolean-attribute.ts diff --git a/core/package-lock.json b/core/package-lock.json index 14b7aad5b32..0be4d276154 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -29,7 +29,7 @@ "@stencil/angular-output-target": "^1.4.1", "@stencil/react-output-target": "^1.6.2", "@stencil/sass": "^3.0.9", - "@stencil/vue-output-target": "0.14.2", + "@stencil/vue-output-target": "0.14.6", "@types/jest": "^29.5.6", "@types/node": "^24.13.3", "@typescript-eslint/eslint-plugin": "^8.0.0", @@ -1829,13 +1829,13 @@ } }, "node_modules/@stencil/angular-output-target": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@stencil/angular-output-target/-/angular-output-target-1.4.1.tgz", - "integrity": "sha512-sy+k9B5jcT6hpaVU9n+/ZUjz3qVr9PAdPgy2c3LFQouW1aFSa3Ng0O+Ia3wk7LMeL3vDetCn2X+p2ewdHGc1vA==", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@stencil/angular-output-target/-/angular-output-target-1.5.2.tgz", + "integrity": "sha512-QzVH3d26mHgEbyVoFOzE5vAOKWSBl7t7VV2npK0LhF0JtdorYqEVWkV7sRyV80j/xodptB50wbWZZ5hQA1VVwg==", "dev": true, "license": "MIT", "peerDependencies": { - "@stencil/core": ">=2.0.0 || >=3 || >=4.0.0 || ^5.0.0 || >=5.0.0-alpha.17 <5.0.0-next.0" + "@stencil/core": ">=2.0.0 || >=3 || >=4.0.0 || ^5.0.0 || >=5.0.0-beta.18" } }, "node_modules/@stencil/core": { @@ -1903,13 +1903,13 @@ } }, "node_modules/@stencil/vue-output-target": { - "version": "0.14.2", - "resolved": "https://registry.npmjs.org/@stencil/vue-output-target/-/vue-output-target-0.14.2.tgz", - "integrity": "sha512-KF09dlRXvieXYEW7MnvnUoAWnQKm3JLori9UiB4xoTdJQeI987nRx5N5zzci+L3j4Ta+932D6+pJuWvHZJb+jA==", + "version": "0.14.6", + "resolved": "https://registry.npmjs.org/@stencil/vue-output-target/-/vue-output-target-0.14.6.tgz", + "integrity": "sha512-IkgRf6UjoZg83CWguSxiRMqNLIRI29C0SBDj3m6OuB5ShVDqt2idV+kROdHhzR9DQFEPbKZqp7XNJIfrYN1zhg==", "dev": true, "license": "MIT", "peerDependencies": { - "@stencil/core": ">=2.0.0 || >=3 || >=4.0.0 || ^5.0.0 || >=5.0.0-alpha.17 <5.0.0-next.0", + "@stencil/core": ">=2.0.0 || >=3 || >=4.0.0 || ^5.0.0 || >=5.0.0-beta.18", "vue": "^3.4.38", "vue-router": "^4.5.0 || ^5.0.0" }, diff --git a/core/package.json b/core/package.json index 6554c74ccd2..11225fe91eb 100644 --- a/core/package.json +++ b/core/package.json @@ -86,7 +86,7 @@ "@stencil/angular-output-target": "^1.4.1", "@stencil/react-output-target": "^1.6.2", "@stencil/sass": "^3.0.9", - "@stencil/vue-output-target": "0.14.2", + "@stencil/vue-output-target": "0.14.6", "@types/jest": "^29.5.6", "@types/node": "^24.13.3", "@typescript-eslint/eslint-plugin": "^8.0.0", diff --git a/packages/angular/.prettierignore b/packages/angular/.prettierignore index daee675330e..3fbe2f57cab 100644 --- a/packages/angular/.prettierignore +++ b/packages/angular/.prettierignore @@ -5,3 +5,4 @@ proxies.ts src/lazy/directives/proxies-list.ts src/standalone/directives/ion-*.ts **/*/angular-component-lib/utils.ts +**/*/angular-component-lib/boolean-attribute.ts diff --git a/packages/angular/eslint.config.js b/packages/angular/eslint.config.js index b5f03c846a2..a3a5fe9c179 100644 --- a/packages/angular/eslint.config.js +++ b/packages/angular/eslint.config.js @@ -20,7 +20,16 @@ const tsConfigs = [...compat.extends('@ionic/eslint-config/recommended'), ...ang module.exports = [ { - ignores: ['dist/**', 'build/**', 'scripts/**', '**/proxies.ts', 'test/**', 'src/standalone/directives/ion-*.ts'], + ignores: [ + 'dist/**', + 'build/**', + 'scripts/**', + '**/proxies.ts', + // Generated by @stencil/angular-output-target alongside the proxies + '**/angular-component-lib/boolean-attribute.ts', + 'test/**', + 'src/standalone/directives/ion-*.ts', + ], }, ...tsConfigs, { diff --git a/packages/angular/src/lazy/directives/angular-component-lib/boolean-attribute.ts b/packages/angular/src/lazy/directives/angular-component-lib/boolean-attribute.ts new file mode 100644 index 00000000000..c116dd5bc7f --- /dev/null +++ b/packages/angular/src/lazy/directives/angular-component-lib/boolean-attribute.ts @@ -0,0 +1,43 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Transforms a value to a boolean so that boolean properties can be set by attribute presence, + * e.g. `` instead of ``. + * + * Strings are coerced the same way Angular's `booleanAttribute` coerces them, so `''` (a bare + * attribute) becomes `true` and `'false'` becomes `false`. + * + * Unlike Angular's `booleanAttribute`, `null` and `undefined` are passed through rather than + * coerced to `false`. Components frequently treat them as a state distinct from `false`: + * + * ```tsx + * // `undefined` means "decide based on the mode", which is not the same as `false` + * const showDetail = detail !== undefined ? detail : mode === 'ios'; + * + * // a strict comparison also behaves differently for `null` than it does for `false` + * const showHandle = handle !== false; + * ``` + * + * Both values reach inputs routinely in Angular templates, from the `async` pipe before its + * first emission and from form control values, so coercing them would change the behavior of + * bindings that work today. + * + * This is implemented here rather than imported from `@angular/core` so that consumers on + * Angular versions without `booleanAttribute` are unaffected by this file being generated. + * + * The parameter type is what Angular derives `ngAcceptInputType_*` from, so it decides which + * template bindings compile. Widening it to `unknown` would let any expression through. + * + * Declared as a function rather than an arrow constant because Angular has to resolve input + * transforms statically when compiling a library in partial compilation mode. + * + * This lives in its own file, separate from `utils.ts`, so that it carries no runtime imports. + * That keeps it independently type-checkable without pulling `rxjs` in for `proxyOutputs`. + */ +export function nullableBooleanAttribute(value: boolean | string | null | undefined): boolean | null | undefined { + if (value === null || value === undefined) { + return value; + } + return typeof value === 'boolean' ? value : value !== 'false'; +} diff --git a/packages/angular/src/standalone/directives/angular-component-lib/boolean-attribute.ts b/packages/angular/src/standalone/directives/angular-component-lib/boolean-attribute.ts new file mode 100644 index 00000000000..c116dd5bc7f --- /dev/null +++ b/packages/angular/src/standalone/directives/angular-component-lib/boolean-attribute.ts @@ -0,0 +1,43 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Transforms a value to a boolean so that boolean properties can be set by attribute presence, + * e.g. `` instead of ``. + * + * Strings are coerced the same way Angular's `booleanAttribute` coerces them, so `''` (a bare + * attribute) becomes `true` and `'false'` becomes `false`. + * + * Unlike Angular's `booleanAttribute`, `null` and `undefined` are passed through rather than + * coerced to `false`. Components frequently treat them as a state distinct from `false`: + * + * ```tsx + * // `undefined` means "decide based on the mode", which is not the same as `false` + * const showDetail = detail !== undefined ? detail : mode === 'ios'; + * + * // a strict comparison also behaves differently for `null` than it does for `false` + * const showHandle = handle !== false; + * ``` + * + * Both values reach inputs routinely in Angular templates, from the `async` pipe before its + * first emission and from form control values, so coercing them would change the behavior of + * bindings that work today. + * + * This is implemented here rather than imported from `@angular/core` so that consumers on + * Angular versions without `booleanAttribute` are unaffected by this file being generated. + * + * The parameter type is what Angular derives `ngAcceptInputType_*` from, so it decides which + * template bindings compile. Widening it to `unknown` would let any expression through. + * + * Declared as a function rather than an arrow constant because Angular has to resolve input + * transforms statically when compiling a library in partial compilation mode. + * + * This lives in its own file, separate from `utils.ts`, so that it carries no runtime imports. + * That keeps it independently type-checkable without pulling `rxjs` in for `proxyOutputs`. + */ +export function nullableBooleanAttribute(value: boolean | string | null | undefined): boolean | null | undefined { + if (value === null || value === undefined) { + return value; + } + return typeof value === 'boolean' ? value : value !== 'false'; +} From 49132e83b21df78193c90ae2593e45672e042701 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:20:07 +0000 Subject: [PATCH 04/12] chore(deps): update dependency vitest to v5.0.5 (#31576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [vitest](https://vitest.dev) ([source](https://redirect.github.com/vitest-dev/vitest/tree/HEAD/packages/vitest)) | [`5.0.4` → `5.0.5`](https://renovatebot.com/diffs/npm/vitest/5.0.4/5.0.5) | ![age](https://developer.mend.io/api/mc/badges/age/npm/vitest/5.0.5?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/npm/vitest/5.0.4/5.0.5?slim=true) | --- ### Release Notes
vitest-dev/vitest (vitest) ### [`v5.0.5`](https://redirect.github.com/vitest-dev/vitest/releases/tag/v5.0.5) [Compare Source](https://redirect.github.com/vitest-dev/vitest/compare/v5.0.4...v5.0.5) #####    🐞 Bug Fixes - Keep vite default `fs.deny` with api token file  -  by [@​hi-ogawa](https://redirect.github.com/hi-ogawa), **Hiroshi Ogawa** and **Claude Opus 5.5** in [#​11578](https://redirect.github.com/vitest-dev/vitest/issues/11578) [(28dc1)](https://redirect.github.com/vitest-dev/vitest/commit/28dc1edfb) - Handle quoted file names in `--changed`  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​11583](https://redirect.github.com/vitest-dev/vitest/issues/11583) [(14921)](https://redirect.github.com/vitest-dev/vitest/commit/14921e962) - Add `data` to the `import.meta.hot` stub  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​11590](https://redirect.github.com/vitest-dev/vitest/issues/11590) [(3a828)](https://redirect.github.com/vitest-dev/vitest/commit/3a8289ac3) - Set `skip` in `skipIf`, ignore `undefined` options  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​11591](https://redirect.github.com/vitest-dev/vitest/issues/11591) [(d523a)](https://redirect.github.com/vitest-dev/vitest/commit/d523a15f2) - Skip builtin loading in async leak detector  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​11598](https://redirect.github.com/vitest-dev/vitest/issues/11598) [(fe86a)](https://redirect.github.com/vitest-dev/vitest/commit/fe86a64c6) - Keep the import frame in native loader errors  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​11599](https://redirect.github.com/vitest-dev/vitest/issues/11599) [(9697b)](https://redirect.github.com/vitest-dev/vitest/commit/9697b3894) - Run hook cleanups when a later hook fails  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​11605](https://redirect.github.com/vitest-dev/vitest/issues/11605) [(6c5a4)](https://redirect.github.com/vitest-dev/vitest/commit/6c5a44720) - Release mocks of finished test files  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​11607](https://redirect.github.com/vitest-dev/vitest/issues/11607) [(e9c3d)](https://redirect.github.com/vitest-dev/vitest/commit/e9c3d72f0) - Abort all running tests on cancel  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​11606](https://redirect.github.com/vitest-dev/vitest/issues/11606) [(6621a)](https://redirect.github.com/vitest-dev/vitest/commit/6621a72d6) - **browser**: - Ignore synthetic pagehide events  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​11582](https://redirect.github.com/vitest-dev/vitest/issues/11582) [(ca081)](https://redirect.github.com/vitest-dev/vitest/commit/ca0813584) - Run plugin buildStart on server creation  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​11593](https://redirect.github.com/vitest-dev/vitest/issues/11593) [(ea611)](https://redirect.github.com/vitest-dev/vitest/commit/ea611e44c) - Drop late connections of closed sessions  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​11600](https://redirect.github.com/vitest-dev/vitest/issues/11600) [(f161e)](https://redirect.github.com/vitest-dev/vitest/commit/f161e6df2) - Wait 30s for the browser to close  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​11601](https://redirect.github.com/vitest-dev/vitest/issues/11601) [(6498b)](https://redirect.github.com/vitest-dev/vitest/commit/6498b666a) - Run global setup once with instances  -  by [@​sheremet-va](https://redirect.github.com/sheremet-va) in [#​11596](https://redirect.github.com/vitest-dev/vitest/issues/11596) [(8dd67)](https://redirect.github.com/vitest-dev/vitest/commit/8dd67c262) - **reporter**: - End reason `&#​39;failed&#​39;` on worker crash  -  by [@​AriPerkkio](https://redirect.github.com/AriPerkkio) in [#​11568](https://redirect.github.com/vitest-dev/vitest/issues/11568) [(6ae8e)](https://redirect.github.com/vitest-dev/vitest/commit/6ae8e89ff) #####     [View changes on GitHub](https://redirect.github.com/vitest-dev/vitest/compare/v5.0.4...v5.0.5)
--- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - "every weekday before 11am" - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Never, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/ionic-team/ionic-framework). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- packages/migrate/package-lock.json | 32 +++++++++++++++--------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/migrate/package-lock.json b/packages/migrate/package-lock.json index b2ec542f166..81569b6c43b 100644 --- a/packages/migrate/package-lock.json +++ b/packages/migrate/package-lock.json @@ -980,14 +980,14 @@ } }, "node_modules/@vitest/mocker": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.4.tgz", - "integrity": "sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz", + "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.31", - "@vitest/spy": "5.0.4", + "@vitest/spy": "5.0.5", "estree-walker": "^3.0.3", "magic-string": "^1.4.3" }, @@ -1008,9 +1008,9 @@ } }, "node_modules/@vitest/spy": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.4.tgz", - "integrity": "sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz", + "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==", "dev": true, "license": "MIT", "funding": { @@ -1509,14 +1509,14 @@ } }, "node_modules/vitest": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.4.tgz", - "integrity": "sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz", + "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==", "dev": true, "license": "MIT", "dependencies": { "@types/chai": "^5.2.2", - "@vitest/mocker": "5.0.4", + "@vitest/mocker": "5.0.5", "chai": "^6.3.0", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", @@ -1542,12 +1542,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "5.0.4", - "@vitest/browser-preview": "5.0.4", + "@vitest/browser-playwright": "5.0.5", + "@vitest/browser-preview": "5.0.5", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", - "@vitest/coverage-istanbul": "5.0.4", - "@vitest/coverage-v8": "5.0.4", - "@vitest/ui": "5.0.4", + "@vitest/coverage-istanbul": "5.0.5", + "@vitest/coverage-v8": "5.0.5", + "@vitest/ui": "5.0.5", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" From 2e46d429ed3a9fb945437ea9e6e1e85e4eda54ca Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:21:07 +0000 Subject: [PATCH 05/12] chore(deps): update github/codeql-action action to v4.39.0 (#31577) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [github/codeql-action](https://redirect.github.com/github/codeql-action) | action | minor | `v4.38.3` → `v4.39.0` | --- ### Release Notes
github/codeql-action ### [`v4.39.0`](https://redirect.github.com/github/codeql-action/releases/tag/v4.39.0) [Compare Source](https://redirect.github.com/github/codeql-action/compare/v4.38.3...v4.39.0) - *Breaking change*: Bump the minimum required CodeQL bundle version to 2.20.7. [#​4205](https://redirect.github.com/github/codeql-action/pull/4205) - The `codeql-action/analyze` step now validates that a correct argument for the `checkout_path` input has been provided. The `checkout_path` input must be set to the path where the repository that is being analyzed is checked out at on the runner, if that path is something other than the workspace root. If the path does not point to a repository checkout or is different from the path that was used by `codeql-action/init`, then the CodeQL Action now produces a warning. [#​4148](https://redirect.github.com/github/codeql-action/pull/4148) - Resolved an issue with third-party caching implementations that prevented caches from being restored correctly. [#​4206](https://redirect.github.com/github/codeql-action/pull/4206) - The `codeql-action/start-proxy` step that is used in Code Scanning Default Setup now finds CodeQL releases using the same logic as `codeql-action/init`. This primarily benefits customers who are using GitHub instances other than `github.com` where the `codeql-action/start-proxy` step may previously have been unable to locate recent releases and ended up using outdated versions of the private registry authentication proxy as a result. [#​4198](https://redirect.github.com/github/codeql-action/pull/4198)
--- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - "every weekday before 11am" - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Enabled. ♻ **Rebasing**: Never, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/ionic-team/ionic-framework). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 1fddfeb7fa0..d98254053da 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -15,7 +15,7 @@ jobs: security-events: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 + - uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 with: languages: javascript - - uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 + - uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 From 6fddbad8b209a184bf2c538acf1ec86e3380ca5d Mon Sep 17 00:00:00 2001 From: Shane Date: Mon, 19 Oct 2026 18:05:42 +0000 Subject: [PATCH 06/12] test(vue): cover tab bar used without tabs (#31589) Issue number: resolves #30361 --------- ## What is the current behavior? In v8, using `ion-tab-bar` without `ion-tabs` throws in Vue because `IonTabBar` injects `tabBarData` with no default and reads `.value` off `undefined`. Clicking a tab button then throws again in the native event handler. This was already fixed in v9 by the strict type checking chore in #31320, which gave the injection a `null` default and guarded it, but nothing tests the standalone case in a browser. ## What is the new behavior? This adds a `/tabs-standalone` page to the Vue test app with an `ion-tab-bar` that has no `ion-tabs` or `ion-router-outlet` around it, where the app tracks the active tab itself and navigates by calling `router.push` from the tab buttons. The new Playwright spec checks that clicking moves the selected state, runs the app's own `@click`, doesn't change the URL, logs no errors or warnings, and that `router.push` from a tab button navigates. ## Does this introduce a breaking change? - [ ] Yes - [X] No ## Other information I checked that the spec catches the original bug by patching the built `@ionic/vue` back to the v8 `inject("tabBarData")`. Both tests fail with the errors from the issue, and they pass on this branch. This is test-only, so the v8 behavior is unchanged. We aren't backporting the fix since it isn't critical. The follow-up request in the issue for a page animation when switching tabs by method isn't covered here and is out of scope for the issue it's in. --- packages/vue/test/base/src/router/index.ts | 4 ++ packages/vue/test/base/src/views/Home.vue | 3 ++ .../test/base/src/views/TabsStandalone.vue | 44 +++++++++++++++ .../e2e/playwright/tab-bar-standalone.spec.ts | 54 +++++++++++++++++++ 4 files changed, 105 insertions(+) create mode 100644 packages/vue/test/base/src/views/TabsStandalone.vue create mode 100644 packages/vue/test/base/tests/e2e/playwright/tab-bar-standalone.spec.ts diff --git a/packages/vue/test/base/src/router/index.ts b/packages/vue/test/base/src/router/index.ts index ce5e63de52e..8d094cc9b18 100644 --- a/packages/vue/test/base/src/router/index.ts +++ b/packages/vue/test/base/src/router/index.ts @@ -200,6 +200,10 @@ const routes: Array = [ path: '/tabs-basic', component: () => import('@/views/TabsBasic.vue') }, + { + path: '/tabs-standalone', + component: () => import('@/views/TabsStandalone.vue') + }, { path: '/tabs-search-params/', component: () => import('@/views/tabs-search-params/TabsSearchParams.vue'), diff --git a/packages/vue/test/base/src/views/Home.vue b/packages/vue/test/base/src/views/Home.vue index b8fbfdf04e7..505d5295bb0 100644 --- a/packages/vue/test/base/src/views/Home.vue +++ b/packages/vue/test/base/src/views/Home.vue @@ -53,6 +53,9 @@ Tabs with Basic Navigation + + Tab Bar without Tabs + Tabs with Similar Route Prefixes diff --git a/packages/vue/test/base/src/views/TabsStandalone.vue b/packages/vue/test/base/src/views/TabsStandalone.vue new file mode 100644 index 00000000000..0415ff555f0 --- /dev/null +++ b/packages/vue/test/base/src/views/TabsStandalone.vue @@ -0,0 +1,44 @@ + + + diff --git a/packages/vue/test/base/tests/e2e/playwright/tab-bar-standalone.spec.ts b/packages/vue/test/base/tests/e2e/playwright/tab-bar-standalone.spec.ts new file mode 100644 index 00000000000..c4076db86a8 --- /dev/null +++ b/packages/vue/test/base/tests/e2e/playwright/tab-bar-standalone.spec.ts @@ -0,0 +1,54 @@ +import { test, expect } from './utils/test-base'; +import { ionPageVisible, tabClick } from './utils/test-utils'; + +/** + * https://github.com/ionic-team/ionic-framework/issues/30361 + * ion-tab-bar used without ion-tabs or ion-router-outlet, with the app + * navigating by method from the tab buttons. + */ +test.describe('Tab Bar: standalone', () => { + test('should switch tabs on click without errors', async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', (err) => errors.push(err.message)); + page.on('console', (msg) => { + if (msg.type() === 'error' || msg.type() === 'warning') { + errors.push(msg.text()); + } + }); + + await page.goto('/tabs-standalone'); + await ionPageVisible(page, 'tabs-standalone'); + + const tab1 = page.locator('ion-tab-button#tab-button-tab1'); + const tab2 = page.locator('ion-tab-button#tab-button-tab2'); + + await expect(tab1).toHaveClass(/tab-selected/); + await expect(page.locator('#active-tab')).toHaveText('tab1'); + + await tabClick(page, 'tab2'); + + await expect(tab2).toHaveClass(/tab-selected/); + await expect(tab1).not.toHaveClass(/tab-selected/); + await expect(page.locator('#active-tab')).toHaveText('tab2'); + // With no router outlet, a tab button must not change the URL itself. + await expect(page).toHaveURL(/\/tabs-standalone(\?|$)/); + + await tabClick(page, 'tab1'); + + await expect(tab1).toHaveClass(/tab-selected/); + await expect(tab2).not.toHaveClass(/tab-selected/); + await expect(page.locator('#active-tab')).toHaveText('tab1'); + + expect(errors).toEqual([]); + }); + + test('should navigate by method from a tab button', async ({ page }) => { + await page.goto('/tabs-standalone'); + await ionPageVisible(page, 'tabs-standalone'); + + await tabClick(page, 'routing'); + + await ionPageVisible(page, 'routing'); + await expect(page).toHaveURL(/\/routing(\?|$)/); + }); +}); From cb963f886ee08ce3abf25d6cb4e33c5cc3ac0003 Mon Sep 17 00:00:00 2001 From: rudrakshagarwal4200-ai Date: Mon, 19 Oct 2026 20:42:33 +0000 Subject: [PATCH 07/12] fix(angular): import tab proxies directly in standalone tabs (#31596) ### Summary Fixes #31594. In `packages/angular/src/standalone/navigation/tabs.ts`, `IonTabs` was importing `IonTabBar` and `IonTab` from `../directives/proxies`. Because `directives/proxies` is the barrel that re-exports every component proxy, bundlers pulling `IonTabs` into the root route included all lazy-route Ionic components into the initial chunk, defeating standalone tree-shaking and component code-splitting. ### Fix Import `IonTab` from `../directives/ion-tab` and `IonTabBar` from `../directives/ion-tab-bar` directly, matching per-component entry points and preventing the entire proxies barrel from bundling into initial route chunks. --- packages/angular/src/standalone/navigation/tabs.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/angular/src/standalone/navigation/tabs.ts b/packages/angular/src/standalone/navigation/tabs.ts index 486e7afe64b..ccce63094c7 100644 --- a/packages/angular/src/standalone/navigation/tabs.ts +++ b/packages/angular/src/standalone/navigation/tabs.ts @@ -2,7 +2,8 @@ import { NgIf } from '@angular/common'; import { ChangeDetectionStrategy, Component, ContentChild, ContentChildren, ViewChild, QueryList } from '@angular/core'; import { IonTabs as IonTabsBase } from '@ionic/angular/common'; -import { IonTabBar, IonTab } from '../directives/proxies'; +import { IonTab } from '../directives/ion-tab'; +import { IonTabBar } from '../directives/ion-tab-bar'; import { IonRouterOutlet } from './router-outlet'; From cecd3afdaea91a2e5f4ada6cd4aaedf6f436e6c9 Mon Sep 17 00:00:00 2001 From: Shane Date: Tue, 20 Oct 2026 12:42:04 +0000 Subject: [PATCH 08/12] fix(datetime): discard unconfirmed selection on cancel (#31558) Issue number: resolves #30777, resolves #27975 --------- ## What is the current behavior? Currently, when `ion-datetime` is inside a modal or popover (usually through `ion-datetime-button`), cancelling or dismissing the overlay leaves the unconfirmed selection in place. The `cancel()` method only emits `ionCancel` and closes the overlay, so reopening the datetime shows the cancelled date as selected even though `value` never changed. ## What is the new behavior? The datetime now listens for its parent overlay's dismiss event and resets its selection to `value` unless the overlay was dismissed with the `datetime-confirm` role. That covers the Cancel button, backdrop taps, swipes and hardware back. The reset skips the month animation since the overlay has already animated out, otherwise cancelling a selection in another month left the calendar stuck on that month when reopened. Calling `cancel()` on an inline datetime now restores the committed selection right away. ## Does this introduce a breaking change? - [ ] Yes - [X] No ## Other information There was an earlier attempt at this in #27976 that called `reset()` from `cancel()`, which was closed until the design was settled. This reprocesses `value` directly instead, so it doesn't pick up the clearing behavior of `reset()`. This changes behavior in a few edge cases: - Calling `cancel()` without closing an overlay now resets the selection, where before it only emitted `ionCancel`. - If `canDismiss` blocks the dismiss, the selection stays, since the user is still in the picker. - An unconfirmed selection is now discarded the next time the overlay opens, so calling `confirm()` or `reset()` from a dismiss handler works the same as on `main`. Browsing to another month without picking a day isn't reset either, which also matches `main`. Use the "Popover - Custom" and "Modal - Custom" examples, pick a date, cancel, then reopen: - [Relevant test screen (iOS)](https://ionic-framework-git-fw-7061-ionic1.vercel.app/src/components/datetime-button/test/overlays?ionic:mode=ios) - [Relevant test screen (MD)](https://ionic-framework-git-fw-7061-ionic1.vercel.app/src/components/datetime-button/test/overlays?ionic:mode=md) --- core/src/components.d.ts | 2 +- core/src/components/datetime/datetime.tsx | 70 +++- .../datetime/test/cancel/datetime.e2e.ts | 327 ++++++++++++++++++ 3 files changed, 391 insertions(+), 8 deletions(-) create mode 100644 core/src/components/datetime/test/cancel/datetime.e2e.ts diff --git a/core/src/components.d.ts b/core/src/components.d.ts index cf24c5fcd4f..0675a18b9c0 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -912,7 +912,7 @@ export namespace Components { } interface IonDatetime { /** - * Emits the ionCancel event and optionally closes the popover or modal that the datetime was presented in. + * Emits the ionCancel event, discards any unconfirmed selection, and optionally closes the popover or modal that the datetime was presented in. * @param closeOverlay If `true`, closes the parent overlay. Defaults to `false`. */ "cancel": (closeOverlay?: boolean) => Promise; diff --git a/core/src/components/datetime/datetime.tsx b/core/src/components/datetime/datetime.tsx index e9bb5d47086..4851f70df70 100644 --- a/core/src/components/datetime/datetime.tsx +++ b/core/src/components/datetime/datetime.tsx @@ -129,6 +129,14 @@ export class Datetime implements ComponentInterface { private destroyCalendarListener?: () => void; private destroyKeyboardMO?: () => void; + private destroyOverlayPresentListener?: () => void; + /** + * Set when the user selects a value, cleared when it's confirmed + * or `processValue` runs (`reset()`, or `value` being set to a + * non-empty value). The overlay present listener only discards a + * selection while this is set. + */ + private hasUnconfirmedSelection = false; // TODO(FW-2832): types (DatetimeParts causes some errors that need untangling) private minParts?: any; @@ -565,6 +573,8 @@ export class Datetime implements ComponentInterface { } } + this.hasUnconfirmedSelection = false; + if (closeOverlay) { this.closeParentOverlay(CONFIRM_ROLE); } @@ -583,9 +593,10 @@ export class Datetime implements ComponentInterface { } /** - * Emits the ionCancel event and - * optionally closes the popover - * or modal that the datetime was + * Emits the ionCancel event, discards + * any unconfirmed selection, and + * optionally closes the popover or + * modal that the datetime was * presented in. * * @param closeOverlay If `true`, closes the parent overlay. Defaults to `false`. @@ -594,8 +605,12 @@ export class Datetime implements ComponentInterface { async cancel(closeOverlay = false) { this.ionCancel.emit(); - if (closeOverlay) { - this.closeParentOverlay(CANCEL_ROLE); + /** + * When a parent overlay closes, the present listener discards + * the selection on the next open. Otherwise discard it now. + */ + if (!closeOverlay || !this.closeParentOverlay(CANCEL_ROLE)) { + this.processValue(this.value); } } @@ -665,6 +680,8 @@ export class Datetime implements ComponentInterface { if (popoverOrModal) { popoverOrModal.dismiss(undefined, role); } + + return popoverOrModal !== null; }; private setWorkingParts = (parts: DatetimeParts) => { @@ -682,6 +699,8 @@ export class Datetime implements ComponentInterface { return; } + this.hasUnconfirmedSelection = true; + const { multiple, minParts, maxParts, activeParts } = this; /** @@ -1102,6 +1121,7 @@ export class Datetime implements ComponentInterface { this.loadTimeout = setTimeout(() => { this.ensureReadyIfVisible(); }, 100); + this.initializeOverlayPresentListener(); } disconnectedCallback() { @@ -1110,6 +1130,39 @@ export class Datetime implements ComponentInterface { this.clearFocusVisible = undefined; } this.loadTimeoutCleanup(); + + if (this.destroyOverlayPresentListener) { + this.destroyOverlayPresentListener(); + this.destroyOverlayPresentListener = undefined; + } + } + + /** + * A datetime in a modal or popover stays mounted between + * presentations, so an unconfirmed selection would still show when + * it reopens. Discarding on present rather than dismiss lets app + * code that runs on dismiss still confirm or reset the selection, + * whatever order its listeners were added in. + */ + private initializeOverlayPresentListener() { + const overlay = this.el.closest('ion-modal, ion-popover'); + if (!overlay) { + return; + } + + const eventName = overlay.tagName === 'ION-MODAL' ? 'ionModalWillPresent' : 'ionPopoverWillPresent'; + const onPresent = (ev: Event) => { + // Ignore nested overlays and skip when there's nothing to discard. + if (ev.target !== overlay || !this.hasUnconfirmedSelection) { + return; + } + + // The overlay is still hidden, so skip the month animation. + this.processValue(this.value, false); + }; + + overlay.addEventListener(eventName, onPresent); + this.destroyOverlayPresentListener = () => overlay.removeEventListener(eventName, onPresent); } /** @@ -1363,7 +1416,9 @@ export class Datetime implements ComponentInterface { }); } - private processValue = (value?: string | string[] | null) => { + private processValue = (value?: string | string[] | null, animated = true) => { + this.hasUnconfirmedSelection = false; + const hasValue = value !== null && value !== undefined && value !== '' && (!Array.isArray(value) || value.length > 0); const valueToProcess = hasValue ? parseDate(value) : this.defaultParts; @@ -1435,13 +1490,14 @@ export class Datetime implements ComponentInterface { const bodyIsVisible = el.classList.contains('datetime-ready'); const { isGridStyle, showMonthAndYear } = this; - if (isGridStyle && didChangeMonth && bodyIsVisible && !showMonthAndYear) { + if (isGridStyle && didChangeMonth && bodyIsVisible && !showMonthAndYear && animated) { /** * Only animate if: * 1. We're using grid style (wheel style pickers should just jump to new value) * 2. The month and/or year actually changed, and both are defined (otherwise there's nothing to animate to) * 3. The calendar body is visible (prevents animation when in collapsed datetime-button, for example) * 4. The month/year picker is not open (since you wouldn't see the animation anyway) + * 5. The caller did not opt out (e.g. the parent overlay is still hidden) */ this.animateToDate(targetValue); } else { diff --git a/core/src/components/datetime/test/cancel/datetime.e2e.ts b/core/src/components/datetime/test/cancel/datetime.e2e.ts new file mode 100644 index 00000000000..f6b68dc6b2c --- /dev/null +++ b/core/src/components/datetime/test/cancel/datetime.e2e.ts @@ -0,0 +1,327 @@ +import type { Locator } from '@playwright/test'; +import { expect } from '@playwright/test'; +import type { E2EPage } from '@utils/test/playwright'; +import { configs, test } from '@utils/test/playwright'; + +const getDay = (datetime: Locator, day: number, month = 5) => + datetime.locator(`.calendar-day[data-day="${day}"][data-month="${month}"]`); + +const expectSelectedDay = async (datetime: Locator, selected: number, unselected: number) => { + await expect(getDay(datetime, selected)).toHaveClass(/calendar-day-active/); + await expect(getDay(datetime, unselected)).not.toHaveClass(/calendar-day-active/); +}; + +const openDatetime = async (page: E2EPage, didPresent: string) => { + const ionDidPresent = await page.spyOnEvent(didPresent); + await page.locator('ion-datetime-button #date-button').click(); + await ionDidPresent.next(); + await page.locator('ion-datetime.datetime-ready').waitFor(); +}; + +const dismissDatetime = async (page: E2EPage, didDismiss: string, dismiss: () => Promise) => { + const ionDidDismiss = await page.spyOnEvent(didDismiss); + await dismiss(); + await ionDidDismiss.next(); +}; + +const dismissWithRole = (page: E2EPage, tag: string, role: string) => + page + .locator(tag) + .evaluate((el: HTMLIonModalElement | HTMLIonPopoverElement, role: string) => el.dismiss(undefined, role), role); + +const confirmOnDismiss = (page: E2EPage, tag: string, didDismiss: string) => + page + .locator(tag) + .evaluate( + (el: HTMLElement, eventName: string) => + el.addEventListener(eventName, () => el.querySelector('ion-datetime')!.confirm()), + didDismiss + ); + +const overlays = [ + { tag: 'ion-modal', didPresent: 'ionModalDidPresent', didDismiss: 'ionModalDidDismiss' }, + { tag: 'ion-popover', didPresent: 'ionPopoverDidPresent', didDismiss: 'ionPopoverDidDismiss' }, +]; + +/** + * This behavior does not vary across modes/directions. + */ +configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ config, title }) => { + test.describe(title('datetime: cancel'), () => { + overlays.forEach(({ tag, didPresent, didDismiss }) => { + test.describe(`in an ${tag}`, () => { + let datetime: Locator; + + test.beforeEach(async ({ page }) => { + await page.setContent( + ` + + <${tag}> + + + `, + config + ); + + datetime = page.locator('ion-datetime'); + }); + + test('should show the confirmed date when reopened after cancelling a new selection', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30777', + }); + + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await dismissDatetime(page, didDismiss, () => datetime.locator('#cancel-button').click()); + await openDatetime(page, didPresent); + + await expect(datetime).toHaveJSProperty('value', '2022-05-03'); + await expectSelectedDay(datetime, 3, 10); + }); + + test('should show the confirmed date when reopened after dismissing without confirming', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30777', + }); + + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await dismissDatetime(page, didDismiss, () => dismissWithRole(page, tag, 'backdrop')); + await openDatetime(page, didPresent); + + await expect(datetime).toHaveJSProperty('value', '2022-05-03'); + await expectSelectedDay(datetime, 3, 10); + }); + + test('should show the confirmed month when reopened after cancelling a selection in another month', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30777', + }); + + const monthYear = datetime.locator('.calendar-month-year-toggle'); + const nextMonthButton = datetime.locator('.calendar-next-prev ion-button').nth(1); + + await openDatetime(page, didPresent); + await nextMonthButton.click(); + await expect(monthYear).toContainText('June 2022'); + await getDay(datetime, 10, 6).click(); + await dismissDatetime(page, didDismiss, () => datetime.locator('#cancel-button').click()); + await openDatetime(page, didPresent); + + await expect(monthYear).toContainText('May 2022'); + await expect(getDay(datetime, 3)).toHaveClass(/calendar-day-active/); + + await nextMonthButton.click(); + await expect(monthYear).toContainText('June 2022'); + await expect(getDay(datetime, 10, 6)).not.toHaveClass(/calendar-day-active/); + }); + + test('should keep the new date when reopened after confirming a selection', async ({ page }) => { + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await dismissDatetime(page, didDismiss, () => datetime.locator('#confirm-button').click()); + await openDatetime(page, didPresent); + + await expect(datetime).toHaveJSProperty('value', '2022-05-10'); + await expectSelectedDay(datetime, 10, 3); + }); + + test('should commit the selection when a dismiss handler confirms it after a custom role', async ({ page }) => { + await confirmOnDismiss(page, tag, didDismiss); + + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await dismissDatetime(page, didDismiss, () => dismissWithRole(page, tag, 'my-custom-role')); + + await expect(datetime).toHaveJSProperty('value', '2022-05-10'); + + await openDatetime(page, didPresent); + await expectSelectedDay(datetime, 10, 3); + }); + + test('should commit the selection when confirmed after awaiting onDidDismiss with a custom role', async ({ + page, + }) => { + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + + await page.locator(tag).evaluate((el: HTMLIonModalElement | HTMLIonPopoverElement) => { + el.onDidDismiss().then(() => el.querySelector('ion-datetime')!.confirm()); + }); + await dismissDatetime(page, didDismiss, () => dismissWithRole(page, tag, 'my-custom-role')); + + await expect(datetime).toHaveJSProperty('value', '2022-05-10'); + + await openDatetime(page, didPresent); + await expectSelectedDay(datetime, 10, 3); + }); + + test('should keep the selection set by reset() after cancelling', async ({ page }) => { + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await dismissDatetime(page, didDismiss, () => datetime.locator('#cancel-button').click()); + + await datetime.evaluate((el: HTMLIonDatetimeElement) => el.reset('2022-05-20')); + await openDatetime(page, didPresent); + + await expectSelectedDay(datetime, 20, 3); + }); + + test('should keep the selection set by reset() before dismissing without confirming', async ({ page }) => { + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await datetime.evaluate((el: HTMLIonDatetimeElement) => el.reset('2022-05-20')); + await dismissDatetime(page, didDismiss, () => dismissWithRole(page, tag, 'backdrop')); + + await openDatetime(page, didPresent); + + await expectSelectedDay(datetime, 20, 3); + }); + + test('should show the value set after dismissing without confirming', async ({ page }) => { + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await dismissDatetime(page, didDismiss, () => dismissWithRole(page, tag, 'backdrop')); + + await datetime.evaluate((el: HTMLIonDatetimeElement) => (el.value = '2022-05-20')); + await openDatetime(page, didPresent); + + await expect(datetime).toHaveJSProperty('value', '2022-05-20'); + await expectSelectedDay(datetime, 20, 10); + }); + + test('should clear the selection when the value is cleared after dismissing without confirming', async ({ + page, + }) => { + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await dismissDatetime(page, didDismiss, () => dismissWithRole(page, tag, 'backdrop')); + + await datetime.evaluate((el: HTMLIonDatetimeElement) => (el.value = null)); + await openDatetime(page, didPresent); + + await expect(datetime).toHaveJSProperty('value', null); + await expect(datetime.locator('.calendar-day-active')).toHaveCount(0); + }); + + /** + * Cancel defers discarding the selection until the overlay + * presents again, so an app that confirms on every dismiss + * commits the cancelled selection. This is intended. + */ + test('should commit a cancelled selection when a dismiss handler confirms it', async ({ page }) => { + await confirmOnDismiss(page, tag, didDismiss); + + await openDatetime(page, didPresent); + await getDay(datetime, 10).click(); + await dismissDatetime(page, didDismiss, () => datetime.locator('#cancel-button').click()); + + await expect(datetime).toHaveJSProperty('value', '2022-05-10'); + + await openDatetime(page, didPresent); + await expectSelectedDay(datetime, 10, 3); + }); + }); + }); + + test('should restore the confirmed date when the cancel button is clicked on an inline datetime', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30777', + }); + + await page.setContent( + '', + config + ); + + const datetime = page.locator('ion-datetime'); + await page.locator('ion-datetime.datetime-ready').waitFor(); + + await getDay(datetime, 10).click(); + await datetime.locator('#cancel-button').click(); + + await expect(datetime).toHaveJSProperty('value', '2022-05-03'); + await expectSelectedDay(datetime, 3, 10); + }); + + test('should show the confirmed date in the wheel picker when reopened after cancelling', async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30777', + }); + + await page.setContent( + ` + + + + + `, + config + ); + + const datetime = page.locator('ion-datetime'); + const dayOptions = datetime.locator('.day-column ion-picker-column-option'); + const activeDay = datetime.locator('.day-column ion-picker-column-option.option-active'); + + await openDatetime(page, 'ionModalDidPresent'); + await dayOptions.filter({ hasText: /^10$/ }).click(); + await expect(activeDay).toHaveText('10'); + await dismissDatetime(page, 'ionModalDidDismiss', () => datetime.locator('#cancel-button').click()); + await openDatetime(page, 'ionModalDidPresent'); + + await expect(datetime).toHaveJSProperty('value', '2022-05-03'); + await expect(activeDay).toHaveText('3'); + }); + + test('should keep the selection when the time picker inside a popover is dismissed', async ({ page }) => { + await page.setContent( + ` + + + + `, + config + ); + + const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent'); + const ionPopoverDidDismiss = await page.spyOnEvent('ionPopoverDidDismiss'); + const parentPopover = page.locator('#parent-popover'); + const datetime = page.locator('ion-datetime'); + + await parentPopover.evaluate((el: HTMLIonPopoverElement) => el.present()); + await ionPopoverDidPresent.next(); + await page.locator('ion-datetime.datetime-ready').waitFor(); + + await getDay(datetime, 10).click(); + + /** + * The time picker opens its own popover. Dismissing it + * shouldn't discard the selection in the parent popover. + */ + await datetime.locator('.time-body').click(); + await ionPopoverDidPresent.next(); + await page.keyboard.press('Escape'); + await ionPopoverDidDismiss.next(); + await page.waitForChanges(); + + await expect(parentPopover).not.toHaveClass(/overlay-hidden/); + await expectSelectedDay(datetime, 10, 3); + }); + }); +}); From 86390f6deab23b58498c3b9ae782f9e0fc5a4e29 Mon Sep 17 00:00:00 2001 From: Shane Date: Tue, 20 Oct 2026 15:24:17 +0000 Subject: [PATCH 09/12] fix(vue-router): keep back working after replacing a page in tabs (#31583) Issue number: resolves #30617 --------- ## What is the current behavior? Currently, replacing a page inside a tab stack breaks going back in two different ways. Calling `useIonRouter().replace()` shows a back button that does nothing, because `replace()` sends the `root` direction, which clears the location history but leaves `pushedByRoute` set. Calling `navigate(path, 'forward', 'replace')` after switching tabs does the opposite, so the URL goes back but the replaced page stays on screen. That one happens because the back button calls `router.go(-3)` and the router assumes the page we left is only one entry ahead. ## What is the new behavior? Now `replace()` uses the `none` direction, so it keeps the pages beneath the one it replaced and still doesn't animate, the same as a plain `router.replace()`. The router also uses the real history delta to find the page we left, and an explicit `navigate(url, 'root', 'replace')` no longer shows a back button since there's nothing to go back to. ## Does this introduce a breaking change? - [ ] Yes - [X] No ## Other information This does change `replace()` for apps that relied on it clearing the history. That only started in 9.0, when #31142 made the router respect the caller's direction, and those apps can switch to `navigate(url, 'root', 'replace')`. I updated the `useIonRouter` docs in ionic-docs to match here: https://github.com/ionic-team/ionic-docs/pull/4794 --- packages/vue-router/src/router.ts | 15 ++++- packages/vue/src/hooks/router.ts | 2 +- .../e2e/playwright/replace-direction.spec.ts | 10 ++- .../tests/e2e/playwright/tabs-replace.spec.ts | 63 +++++++++++++++++++ .../base/tests/unit/router-outlet.spec.ts | 4 +- 5 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 packages/vue/test/base/tests/e2e/playwright/tabs-replace.spec.ts diff --git a/packages/vue-router/src/router.ts b/packages/vue-router/src/router.ts index 365b8454b40..95443549c9e 100644 --- a/packages/vue-router/src/router.ts +++ b/packages/vue-router/src/router.ts @@ -475,9 +475,15 @@ export const createIonRouter = ( currentHistoryPosition ); } else if (incomingRouteParams.routerAction === "pop") { + /** + * Back can skip several entries, e.g. router.go(-3) after a + * tab switch, so use the delta to find the page we left. A + * back done with router.replace has no delta and keeps the + * one-step lookup. + */ leavingLocationInfo = locationHistory.current( initialHistoryPosition, - currentHistoryPosition + 1 + currentHistoryPosition - (delta ?? -1) ); /** @@ -709,7 +715,12 @@ export const createIonRouter = ( routeInfo.lastPathname = currentRouteInfo?.pathname || routeInfo.lastPathname; - routeInfo.pushedByRoute = pushedByRoute; + /** + * A root replace resets the stack, so keeping pushedByRoute + * would show a back button with nowhere to go. + */ + routeInfo.pushedByRoute = + routeInfo.routerDirection === "root" ? undefined : pushedByRoute; /** * Prefer the direction/animation the caller specified on * the navigate call; fall back to the leaving route's diff --git a/packages/vue/src/hooks/router.ts b/packages/vue/src/hooks/router.ts index ed6af2a66b3..6c50c702c77 100644 --- a/packages/vue/src/hooks/router.ts +++ b/packages/vue/src/hooks/router.ts @@ -44,7 +44,7 @@ export const useIonRouter = (): UseIonRouterResult => { navigate(location, "forward", "push", routerAnimation); const replace = (location: any, routerAnimation?: AnimationBuilder) => - navigate(location, "root", "replace", routerAnimation); + navigate(location, "none", "replace", routerAnimation); const back = (routerAnimation?: AnimationBuilder) => goBack(routerAnimation); diff --git a/packages/vue/test/base/tests/e2e/playwright/replace-direction.spec.ts b/packages/vue/test/base/tests/e2e/playwright/replace-direction.spec.ts index e70a1427de4..92024433f12 100644 --- a/packages/vue/test/base/tests/e2e/playwright/replace-direction.spec.ts +++ b/packages/vue/test/base/tests/e2e/playwright/replace-direction.spec.ts @@ -44,7 +44,13 @@ test.describe('useIonRouter.navigate with routerAction="replace"', () => { expect(await readCurrentDirection(page)).toBe('forward'); }); - test('default useIonRouter.replace() keeps direction "root"', async ({ page }) => { + // The default replace keeps the stack beneath the replaced page. + test('default useIonRouter.replace() should use direction "none"', async ({ page }, testInfo) => { + testInfo.annotations.push({ + type: 'issue', + description: 'https://github.com/ionic-team/ionic-framework/issues/30617', + }); + await page.goto('/'); await ionPageVisible(page, 'home'); @@ -52,6 +58,6 @@ test.describe('useIonRouter.navigate with routerAction="replace"', () => { await page.evaluate(() => (window as any).debugIonRouter.replace('/routing')); await ionPageVisible(page, 'routing'); - expect(await readCurrentDirection(page)).toBe('root'); + expect(await readCurrentDirection(page)).toBe('none'); }); }); diff --git a/packages/vue/test/base/tests/e2e/playwright/tabs-replace.spec.ts b/packages/vue/test/base/tests/e2e/playwright/tabs-replace.spec.ts new file mode 100644 index 00000000000..0042e35e099 --- /dev/null +++ b/packages/vue/test/base/tests/e2e/playwright/tabs-replace.spec.ts @@ -0,0 +1,63 @@ +import { test, expect } from './utils/test-base'; +import { ionBackClick, ionPageVisible, ionPageDoesNotExist, ionRouterNavigate, ionRouterReplace, routerPush, tabClick } from './utils/test-utils'; + +/** + * Replacing a page in a tab stack should keep the pages beneath it. + * + * @see https://github.com/ionic-team/ionic-framework/issues/30617 + */ +test.describe('Tabs: replacing a page inside a tab stack', () => { + test('should return to the tab root on back after a tab switch and a forward replace', async ({ page }) => { + await page.goto('/tabs/tab1'); + await ionPageVisible(page, 'tab1'); + + await routerPush(page, '/tabs/tab1/view1'); + await ionPageVisible(page, 'tab1view1'); + + await tabClick(page, 'tab2'); + await ionPageVisible(page, 'tab2'); + + await tabClick(page, 'tab1'); + await ionPageVisible(page, 'tab1view1'); + + await ionRouterNavigate(page, '/tabs/tab1/view2', 'forward', 'replace'); + await ionPageVisible(page, 'tab1view2'); + + await ionBackClick(page, 'tab1view2'); + + await expect.poll(() => new URL(page.url()).pathname).toBe('/tabs/tab1'); + await ionPageVisible(page, 'tab1'); + await ionPageDoesNotExist(page, 'tab1view2'); + }); + + test('should return to the tab root on back after useIonRouter.replace', async ({ page }) => { + await page.goto('/tabs/tab1'); + await ionPageVisible(page, 'tab1'); + + await routerPush(page, '/tabs/tab1/view1'); + await ionPageVisible(page, 'tab1view1'); + + await ionRouterReplace(page, '/tabs/tab1/view2'); + await ionPageVisible(page, 'tab1view2'); + + await ionBackClick(page, 'tab1view2'); + + await expect.poll(() => new URL(page.url()).pathname).toBe('/tabs/tab1'); + await ionPageVisible(page, 'tab1'); + await ionPageDoesNotExist(page, 'tab1view2'); + }); + + // A root replace resets the stack, so there's nothing to go back to. + test('should not show a back button after a root replace', async ({ page }) => { + await page.goto('/tabs/tab1'); + await ionPageVisible(page, 'tab1'); + + await routerPush(page, '/tabs/tab1/view1'); + await ionPageVisible(page, 'tab1view1'); + + await ionRouterNavigate(page, '/tabs/tab1/view2', 'root', 'replace'); + await ionPageVisible(page, 'tab1view2'); + + await expect(page.locator('.ion-page[data-pageid="tab1view2"] ion-back-button')).toBeHidden(); + }); +}); diff --git a/packages/vue/test/base/tests/unit/router-outlet.spec.ts b/packages/vue/test/base/tests/unit/router-outlet.spec.ts index 9c3b544faab..507c29c04f5 100644 --- a/packages/vue/test/base/tests/unit/router-outlet.spec.ts +++ b/packages/vue/test/base/tests/unit/router-outlet.spec.ts @@ -80,7 +80,7 @@ describe('Routing', () => { expect.anything(), expect.objectContaining({ // "root" and "none" both resolve to duration=0 via hasRootDirection. - direction: "root", + direction: "none", duration: 0, animationBuilder: undefined }) @@ -142,7 +142,7 @@ describe('Routing', () => { expect.anything(), expect.anything(), expect.objectContaining({ - direction: "root", + direction: "none", duration: undefined, animationBuilder: animation }) From 584d9731f9a99910b85ebe2ac2f81c5260d54896 Mon Sep 17 00:00:00 2001 From: Maria Hutt Date: Tue, 20 Oct 2026 21:57:35 +0000 Subject: [PATCH 10/12] docs(backdrop): clarify that visible only controls the default background (#31591) Issue number: N/A --------- ## What is the current behavior? The `visible` docs on `ion-backdrop` only say "If `true`, the backdrop will be visible." Developers read that as "hides the backdrop," but `visible="false"` only removes the default background: the backdrop still blocks interaction, and custom background styles on `ion-backdrop` still apply. This has been reported as a bug several times (#26953, #29177, #30742). ## What is the new behavior? - Documents that `visible` controls only the default background, not whether the backdrop blocks interaction or overrides a custom background. The wording matches `showBackdrop` on `ion-modal` and `ion-popover`. ## Does this introduce a breaking change? - [ ] Yes - [x] No ## Other information The Properties table on the docs site is generated from this JSDoc, so it updates on the next API sync. --- core/src/components.d.ts | 4 ++-- core/src/components/backdrop/backdrop.tsx | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 0675a18b9c0..8ea80823481 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -392,7 +392,7 @@ export namespace Components { */ "tappable": boolean; /** - * If `true`, the backdrop will be visible. + * If `true`, the backdrop will be visible. This property controls whether or not the backdrop's default background is shown. It does not control whether or not the backdrop blocks interaction, and it does not override a custom background set on `ion-backdrop`. * @default true */ "visible": boolean; @@ -5512,7 +5512,7 @@ declare namespace LocalJSX { */ "tappable"?: boolean; /** - * If `true`, the backdrop will be visible. + * If `true`, the backdrop will be visible. This property controls whether or not the backdrop's default background is shown. It does not control whether or not the backdrop blocks interaction, and it does not override a custom background set on `ion-backdrop`. * @default true */ "visible"?: boolean; diff --git a/core/src/components/backdrop/backdrop.tsx b/core/src/components/backdrop/backdrop.tsx index 9468f02526c..8a3ff04c837 100644 --- a/core/src/components/backdrop/backdrop.tsx +++ b/core/src/components/backdrop/backdrop.tsx @@ -14,6 +14,10 @@ import { getIonMode } from '../../global/ionic-global'; export class Backdrop implements ComponentInterface { /** * If `true`, the backdrop will be visible. + * This property controls whether or not the backdrop's + * default background is shown. It does not control whether + * or not the backdrop blocks interaction, and it does not + * override a custom background set on `ion-backdrop`. */ @Prop() visible = true; From 585671e58aadbecbc04d8ce1a28a1a54a4b89349 Mon Sep 17 00:00:00 2001 From: ionitron Date: Wed, 21 Oct 2026 18:18:06 +0000 Subject: [PATCH 11/12] v9.0.9 --- CHANGELOG.md | 11 +++++++++++ core/CHANGELOG.md | 9 +++++++++ core/package-lock.json | 6 +++--- core/package.json | 2 +- lerna.json | 2 +- packages/angular-server/CHANGELOG.md | 8 ++++++++ packages/angular-server/package-lock.json | 8 ++++---- packages/angular-server/package.json | 4 ++-- packages/angular/CHANGELOG.md | 8 ++++++++ packages/angular/package-lock.json | 8 ++++---- packages/angular/package.json | 4 ++-- packages/docs/CHANGELOG.md | 8 ++++++++ packages/docs/package-lock.json | 6 +++--- packages/docs/package.json | 2 +- packages/react-router/CHANGELOG.md | 7 +++++++ packages/react-router/package-lock.json | 10 +++++----- packages/react-router/package.json | 6 +++--- packages/react/CHANGELOG.md | 8 ++++++++ packages/react/package-lock.json | 8 ++++---- packages/react/package.json | 4 ++-- packages/vue-router/CHANGELOG.md | 7 +++++++ packages/vue-router/package-lock.json | 8 ++++---- packages/vue-router/package.json | 4 ++-- packages/vue/CHANGELOG.md | 8 ++++++++ packages/vue/package-lock.json | 8 ++++---- packages/vue/package.json | 4 ++-- 26 files changed, 121 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c50200accd0..a1615d78b77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [9.0.9](https://github.com/ionic-team/ionic-framework/compare/v9.0.8...v9.0.9) (2026-10-21) + +### Bug Fixes + +* **angular:** import tab proxies directly in standalone tabs ([#31596](https://github.com/ionic-team/ionic-framework/issues/31596)) ([cb963f8](https://github.com/ionic-team/ionic-framework/commit/cb963f886ee08ce3abf25d6cb4e33c5cc3ac0003)), closes [#31594](https://github.com/ionic-team/ionic-framework/issues/31594) +* **datetime:** discard unconfirmed selection on cancel ([#31558](https://github.com/ionic-team/ionic-framework/issues/31558)) ([cecd3af](https://github.com/ionic-team/ionic-framework/commit/cecd3afdaea91a2e5f4ada6cd4aaedf6f436e6c9)), closes [#30777](https://github.com/ionic-team/ionic-framework/issues/30777) [#27975](https://github.com/ionic-team/ionic-framework/issues/27975) [#27976](https://github.com/ionic-team/ionic-framework/issues/27976) +* **input, select, textarea:** prevent floating label from overflowing while it floats ([#31567](https://github.com/ionic-team/ionic-framework/issues/31567)) ([ecd6f5a](https://github.com/ionic-team/ionic-framework/commit/ecd6f5a7c0ed987f4357b7a0e3954d9291be170d)) +* **nav:** defer non-animated swipe back until the gesture completes ([#31552](https://github.com/ionic-team/ionic-framework/issues/31552)) ([5f3f5fc](https://github.com/ionic-team/ionic-framework/commit/5f3f5fcae2bc83cb4301bc6e47c7e0b64f6d3d76)), closes [#29793](https://github.com/ionic-team/ionic-framework/issues/29793) +* **vue-router:** keep back working after replacing a page in tabs ([#31583](https://github.com/ionic-team/ionic-framework/issues/31583)) ([86390f6](https://github.com/ionic-team/ionic-framework/commit/86390f6deab23b58498c3b9ae782f9e0fc5a4e29)), closes [#30617](https://github.com/ionic-team/ionic-framework/issues/30617) [#31142](https://github.com/ionic-team/ionic-framework/issues/31142) + + ## [9.0.8](https://github.com/ionic-team/ionic-framework/compare/v9.0.7...v9.0.8) (2026-10-14) ### Bug Fixes diff --git a/core/CHANGELOG.md b/core/CHANGELOG.md index 84405878558..57a6c7960d3 100644 --- a/core/CHANGELOG.md +++ b/core/CHANGELOG.md @@ -3,6 +3,15 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [9.0.9](https://github.com/ionic-team/ionic-framework/compare/v9.0.8...v9.0.9) (2026-10-21) + +### Bug Fixes + +* **datetime:** discard unconfirmed selection on cancel ([#31558](https://github.com/ionic-team/ionic-framework/issues/31558)) ([cecd3af](https://github.com/ionic-team/ionic-framework/commit/cecd3afdaea91a2e5f4ada6cd4aaedf6f436e6c9)), closes [#30777](https://github.com/ionic-team/ionic-framework/issues/30777) [#27975](https://github.com/ionic-team/ionic-framework/issues/27975) [#27976](https://github.com/ionic-team/ionic-framework/issues/27976) +* **input, select, textarea:** prevent floating label from overflowing while it floats ([#31567](https://github.com/ionic-team/ionic-framework/issues/31567)) ([ecd6f5a](https://github.com/ionic-team/ionic-framework/commit/ecd6f5a7c0ed987f4357b7a0e3954d9291be170d)) +* **nav:** defer non-animated swipe back until the gesture completes ([#31552](https://github.com/ionic-team/ionic-framework/issues/31552)) ([5f3f5fc](https://github.com/ionic-team/ionic-framework/commit/5f3f5fcae2bc83cb4301bc6e47c7e0b64f6d3d76)), closes [#29793](https://github.com/ionic-team/ionic-framework/issues/29793) + + ## [9.0.8](https://github.com/ionic-team/ionic-framework/compare/v9.0.7...v9.0.8) (2026-10-14) ### Bug Fixes diff --git a/core/package-lock.json b/core/package-lock.json index 0be4d276154..f54472a38c6 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ionic/core", - "version": "9.0.8", + "version": "9.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/core", - "version": "9.0.8", + "version": "9.0.9", "license": "MIT", "dependencies": { "@stencil/core": "^4.44.2", @@ -11661,4 +11661,4 @@ } } } -} +} \ No newline at end of file diff --git a/core/package.json b/core/package.json index 11225fe91eb..51a57c9994b 100644 --- a/core/package.json +++ b/core/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/core", - "version": "9.0.8", + "version": "9.0.9", "description": "Base components for Ionic", "engines": { "node": ">= 16" diff --git a/lerna.json b/lerna.json index 1647d110ed2..b55d0f68f59 100644 --- a/lerna.json +++ b/lerna.json @@ -10,5 +10,5 @@ "packages/vue", "packages/vue-router" ], - "version": "9.0.8" + "version": "9.0.9" } \ No newline at end of file diff --git a/packages/angular-server/CHANGELOG.md b/packages/angular-server/CHANGELOG.md index 663785a3391..28845030c40 100644 --- a/packages/angular-server/CHANGELOG.md +++ b/packages/angular-server/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [9.0.9](https://github.com/ionic-team/ionic-framework/compare/v9.0.8...v9.0.9) (2026-10-21) + +**Note:** Version bump only for package @ionic/angular-server + + + + + ## [9.0.8](https://github.com/ionic-team/ionic-framework/compare/v9.0.7...v9.0.8) (2026-10-14) **Note:** Version bump only for package @ionic/angular-server diff --git a/packages/angular-server/package-lock.json b/packages/angular-server/package-lock.json index 782ce2983b0..abda27400cd 100644 --- a/packages/angular-server/package-lock.json +++ b/packages/angular-server/package-lock.json @@ -1,15 +1,15 @@ { "name": "@ionic/angular-server", - "version": "9.0.8", + "version": "9.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/angular-server", - "version": "9.0.8", + "version": "9.0.9", "license": "MIT", "dependencies": { - "@ionic/core": "^9.0.8" + "@ionic/core": "^9.0.9" }, "devDependencies": { "@angular/animations": "^22.0.0", @@ -7175,4 +7175,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/angular-server/package.json b/packages/angular-server/package.json index 59d85c56385..0f16a94182d 100644 --- a/packages/angular-server/package.json +++ b/packages/angular-server/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/angular-server", - "version": "9.0.8", + "version": "9.0.9", "description": "Angular SSR Module for Ionic", "keywords": [ "ionic", @@ -78,6 +78,6 @@ }, "prettier": "@ionic/prettier-config", "dependencies": { - "@ionic/core": "^9.0.8" + "@ionic/core": "^9.0.9" } } diff --git a/packages/angular/CHANGELOG.md b/packages/angular/CHANGELOG.md index 6b4baf79bc7..1d3e8740831 100644 --- a/packages/angular/CHANGELOG.md +++ b/packages/angular/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [9.0.9](https://github.com/ionic-team/ionic-framework/compare/v9.0.8...v9.0.9) (2026-10-21) + +### Bug Fixes + +* **angular:** import tab proxies directly in standalone tabs ([#31596](https://github.com/ionic-team/ionic-framework/issues/31596)) ([cb963f8](https://github.com/ionic-team/ionic-framework/commit/cb963f886ee08ce3abf25d6cb4e33c5cc3ac0003)), closes [#31594](https://github.com/ionic-team/ionic-framework/issues/31594) +* **nav:** defer non-animated swipe back until the gesture completes ([#31552](https://github.com/ionic-team/ionic-framework/issues/31552)) ([5f3f5fc](https://github.com/ionic-team/ionic-framework/commit/5f3f5fcae2bc83cb4301bc6e47c7e0b64f6d3d76)), closes [#29793](https://github.com/ionic-team/ionic-framework/issues/29793) + + ## [9.0.8](https://github.com/ionic-team/ionic-framework/compare/v9.0.7...v9.0.8) (2026-10-14) ### Bug Fixes diff --git a/packages/angular/package-lock.json b/packages/angular/package-lock.json index 3798985e293..565637c8808 100644 --- a/packages/angular/package-lock.json +++ b/packages/angular/package-lock.json @@ -1,15 +1,15 @@ { "name": "@ionic/angular", - "version": "9.0.8", + "version": "9.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/angular", - "version": "9.0.8", + "version": "9.0.9", "license": "MIT", "dependencies": { - "@ionic/core": "^9.0.8", + "@ionic/core": "^9.0.9", "ionicons": "^8.0.13", "jsonc-parser": "^3.0.0", "tslib": "^2.3.0" @@ -9211,4 +9211,4 @@ "license": "MIT" } } -} +} \ No newline at end of file diff --git a/packages/angular/package.json b/packages/angular/package.json index 61dab86e93a..820f510ed44 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/angular", - "version": "9.0.8", + "version": "9.0.9", "description": "Angular specific wrappers for @ionic/core", "keywords": [ "ionic", @@ -168,7 +168,7 @@ "css/" ], "dependencies": { - "@ionic/core": "^9.0.8", + "@ionic/core": "^9.0.9", "ionicons": "^8.0.13", "jsonc-parser": "^3.0.0", "tslib": "^2.3.0" diff --git a/packages/docs/CHANGELOG.md b/packages/docs/CHANGELOG.md index 153c73a6acd..fd259332472 100644 --- a/packages/docs/CHANGELOG.md +++ b/packages/docs/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [9.0.9](https://github.com/ionic-team/ionic-framework/compare/v9.0.8...v9.0.9) (2026-10-21) + +**Note:** Version bump only for package @ionic/docs + + + + + ## [9.0.8](https://github.com/ionic-team/ionic-framework/compare/v9.0.7...v9.0.8) (2026-10-14) **Note:** Version bump only for package @ionic/docs diff --git a/packages/docs/package-lock.json b/packages/docs/package-lock.json index 8016c922474..43e72c5424b 100644 --- a/packages/docs/package-lock.json +++ b/packages/docs/package-lock.json @@ -1,13 +1,13 @@ { "name": "@ionic/docs", - "version": "9.0.8", + "version": "9.0.9", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@ionic/docs", - "version": "9.0.8", + "version": "9.0.9", "license": "MIT" } } -} +} \ No newline at end of file diff --git a/packages/docs/package.json b/packages/docs/package.json index 43724875634..ca1de91c4d7 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/docs", - "version": "9.0.8", + "version": "9.0.9", "description": "Pre-packaged API documentation for the Ionic docs.", "main": "core.json", "types": "core.d.ts", diff --git a/packages/react-router/CHANGELOG.md b/packages/react-router/CHANGELOG.md index 2cd31e9b24c..c9243f09c62 100644 --- a/packages/react-router/CHANGELOG.md +++ b/packages/react-router/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [9.0.9](https://github.com/ionic-team/ionic-framework/compare/v9.0.8...v9.0.9) (2026-10-21) + +### Bug Fixes + +* **nav:** defer non-animated swipe back until the gesture completes ([#31552](https://github.com/ionic-team/ionic-framework/issues/31552)) ([5f3f5fc](https://github.com/ionic-team/ionic-framework/commit/5f3f5fcae2bc83cb4301bc6e47c7e0b64f6d3d76)), closes [#29793](https://github.com/ionic-team/ionic-framework/issues/29793) + + ## [9.0.8](https://github.com/ionic-team/ionic-framework/compare/v9.0.7...v9.0.8) (2026-10-14) ### Bug Fixes diff --git a/packages/react-router/package-lock.json b/packages/react-router/package-lock.json index bf202eb5d01..63d11df14db 100644 --- a/packages/react-router/package-lock.json +++ b/packages/react-router/package-lock.json @@ -1,16 +1,16 @@ { "name": "@ionic/react-router", - "version": "9.0.8", + "version": "9.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/react-router", - "version": "9.0.8", + "version": "9.0.9", "license": "MIT", "dependencies": { - "@ionic/core": "^9.0.8", - "@ionic/react": "^9.0.8", + "@ionic/core": "^9.0.9", + "@ionic/react": "^9.0.9", "tslib": "*" }, "devDependencies": { @@ -4632,4 +4632,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 744ea00df1c..6865c5be649 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/react-router", - "version": "9.0.8", + "version": "9.0.9", "description": "React Router wrapper for @ionic/react", "keywords": [ "ionic", @@ -37,8 +37,8 @@ "dist/" ], "dependencies": { - "@ionic/core": "^9.0.8", - "@ionic/react": "^9.0.8", + "@ionic/core": "^9.0.9", + "@ionic/react": "^9.0.9", "tslib": "*" }, "peerDependencies": { diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md index 64cb657b909..55fe936d73b 100644 --- a/packages/react/CHANGELOG.md +++ b/packages/react/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [9.0.9](https://github.com/ionic-team/ionic-framework/compare/v9.0.8...v9.0.9) (2026-10-21) + +**Note:** Version bump only for package @ionic/react + + + + + ## [9.0.8](https://github.com/ionic-team/ionic-framework/compare/v9.0.7...v9.0.8) (2026-10-14) ### Bug Fixes diff --git a/packages/react/package-lock.json b/packages/react/package-lock.json index 492e5364495..38750892c71 100644 --- a/packages/react/package-lock.json +++ b/packages/react/package-lock.json @@ -1,15 +1,15 @@ { "name": "@ionic/react", - "version": "9.0.8", + "version": "9.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/react", - "version": "9.0.8", + "version": "9.0.9", "license": "MIT", "dependencies": { - "@ionic/core": "^9.0.8", + "@ionic/core": "^9.0.9", "@stencil/react-output-target": "^1.6.2", "ionicons": "^8.0.13", "tslib": "*" @@ -8837,4 +8837,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/react/package.json b/packages/react/package.json index 06287958443..c0452ad4ac5 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/react", - "version": "9.0.8", + "version": "9.0.9", "description": "React specific wrapper for @ionic/core", "keywords": [ "ionic", @@ -41,7 +41,7 @@ "css/" ], "dependencies": { - "@ionic/core": "^9.0.8", + "@ionic/core": "^9.0.9", "@stencil/react-output-target": "^1.6.2", "ionicons": "^8.0.13", "tslib": "*" diff --git a/packages/vue-router/CHANGELOG.md b/packages/vue-router/CHANGELOG.md index 94277b93959..4878913d2c7 100644 --- a/packages/vue-router/CHANGELOG.md +++ b/packages/vue-router/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [9.0.9](https://github.com/ionic-team/ionic-framework/compare/v9.0.8...v9.0.9) (2026-10-21) + +### Bug Fixes + +* **vue-router:** keep back working after replacing a page in tabs ([#31583](https://github.com/ionic-team/ionic-framework/issues/31583)) ([86390f6](https://github.com/ionic-team/ionic-framework/commit/86390f6deab23b58498c3b9ae782f9e0fc5a4e29)), closes [#30617](https://github.com/ionic-team/ionic-framework/issues/30617) [#31142](https://github.com/ionic-team/ionic-framework/issues/31142) + + ## [9.0.8](https://github.com/ionic-team/ionic-framework/compare/v9.0.7...v9.0.8) (2026-10-14) **Note:** Version bump only for package @ionic/vue-router diff --git a/packages/vue-router/package-lock.json b/packages/vue-router/package-lock.json index fa054cbd403..b9edfd1a352 100644 --- a/packages/vue-router/package-lock.json +++ b/packages/vue-router/package-lock.json @@ -1,15 +1,15 @@ { "name": "@ionic/vue-router", - "version": "9.0.8", + "version": "9.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/vue-router", - "version": "9.0.8", + "version": "9.0.9", "license": "MIT", "dependencies": { - "@ionic/vue": "^9.0.8" + "@ionic/vue": "^9.0.9" }, "devDependencies": { "@eslint/eslintrc": "^3.3.1", @@ -5146,4 +5146,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/vue-router/package.json b/packages/vue-router/package.json index 8b1640b106e..0ca060e90bd 100644 --- a/packages/vue-router/package.json +++ b/packages/vue-router/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/vue-router", - "version": "9.0.8", + "version": "9.0.9", "description": "Vue Router integration for @ionic/vue", "scripts": { "eslint": "eslint src", @@ -44,7 +44,7 @@ }, "homepage": "https://github.com/ionic-team/ionic-framework#readme", "dependencies": { - "@ionic/vue": "^9.0.8" + "@ionic/vue": "^9.0.9" }, "devDependencies": { "@eslint/eslintrc": "^3.3.1", diff --git a/packages/vue/CHANGELOG.md b/packages/vue/CHANGELOG.md index 02fcc0bd61a..6b4ed13adf4 100644 --- a/packages/vue/CHANGELOG.md +++ b/packages/vue/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [9.0.9](https://github.com/ionic-team/ionic-framework/compare/v9.0.8...v9.0.9) (2026-10-21) + +### Bug Fixes + +* **nav:** defer non-animated swipe back until the gesture completes ([#31552](https://github.com/ionic-team/ionic-framework/issues/31552)) ([5f3f5fc](https://github.com/ionic-team/ionic-framework/commit/5f3f5fcae2bc83cb4301bc6e47c7e0b64f6d3d76)), closes [#29793](https://github.com/ionic-team/ionic-framework/issues/29793) +* **vue-router:** keep back working after replacing a page in tabs ([#31583](https://github.com/ionic-team/ionic-framework/issues/31583)) ([86390f6](https://github.com/ionic-team/ionic-framework/commit/86390f6deab23b58498c3b9ae782f9e0fc5a4e29)), closes [#30617](https://github.com/ionic-team/ionic-framework/issues/30617) [#31142](https://github.com/ionic-team/ionic-framework/issues/31142) + + ## [9.0.8](https://github.com/ionic-team/ionic-framework/compare/v9.0.7...v9.0.8) (2026-10-14) ### Bug Fixes diff --git a/packages/vue/package-lock.json b/packages/vue/package-lock.json index 52b0e5a1cd1..87909016178 100644 --- a/packages/vue/package-lock.json +++ b/packages/vue/package-lock.json @@ -1,15 +1,15 @@ { "name": "@ionic/vue", - "version": "9.0.8", + "version": "9.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ionic/vue", - "version": "9.0.8", + "version": "9.0.9", "license": "MIT", "dependencies": { - "@ionic/core": "^9.0.8", + "@ionic/core": "^9.0.9", "@stencil/vue-output-target": "0.14.2", "ionicons": "^8.0.13" }, @@ -5234,4 +5234,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/vue/package.json b/packages/vue/package.json index dbd2839b29e..a319f2dc4d9 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -1,6 +1,6 @@ { "name": "@ionic/vue", - "version": "9.0.8", + "version": "9.0.9", "description": "Vue specific wrapper for @ionic/core", "scripts": { "eslint": "eslint src", @@ -78,7 +78,7 @@ } }, "dependencies": { - "@ionic/core": "^9.0.8", + "@ionic/core": "^9.0.9", "@stencil/vue-output-target": "0.14.2", "ionicons": "^8.0.13" }, From ae6e2bb83f8135323e329cb5be19e16b921185c4 Mon Sep 17 00:00:00 2001 From: ionitron Date: Wed, 21 Oct 2026 18:23:33 +0000 Subject: [PATCH 12/12] chore(): update package lock files --- core/package-lock.json | 2 +- packages/angular-server/package-lock.json | 8 ++++---- packages/angular/package-lock.json | 8 ++++---- packages/docs/package-lock.json | 2 +- packages/react-router/package-lock.json | 16 ++++++++-------- packages/react/package-lock.json | 8 ++++---- packages/vue-router/package-lock.json | 16 ++++++++-------- packages/vue/package-lock.json | 8 ++++---- 8 files changed, 34 insertions(+), 34 deletions(-) diff --git a/core/package-lock.json b/core/package-lock.json index f54472a38c6..9efcb746452 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -11661,4 +11661,4 @@ } } } -} \ No newline at end of file +} diff --git a/packages/angular-server/package-lock.json b/packages/angular-server/package-lock.json index abda27400cd..db1c8718df5 100644 --- a/packages/angular-server/package-lock.json +++ b/packages/angular-server/package-lock.json @@ -1097,9 +1097,9 @@ } }, "node_modules/@ionic/core": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.8.tgz", - "integrity": "sha512-0KmlxdVWb8dryWTyX69bnH54764jiQnqxX9RHZE313xpyunxDl8UfdxpLiKRWKocawOWa/YgOI4GaHX2RODEGw==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.9.tgz", + "integrity": "sha512-+8MlijXbYbnCoRuexWuPIYxigh9ZsU/X5iXA7mzFBKWzRb7t33uibGiPl1VIF2RG3oLLxeSySewH98R3LAd9Vg==", "license": "MIT", "dependencies": { "@stencil/core": "^4.44.2", @@ -7175,4 +7175,4 @@ } } } -} \ No newline at end of file +} diff --git a/packages/angular/package-lock.json b/packages/angular/package-lock.json index 565637c8808..61484bc67cb 100644 --- a/packages/angular/package-lock.json +++ b/packages/angular/package-lock.json @@ -1580,9 +1580,9 @@ } }, "node_modules/@ionic/core": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.8.tgz", - "integrity": "sha512-0KmlxdVWb8dryWTyX69bnH54764jiQnqxX9RHZE313xpyunxDl8UfdxpLiKRWKocawOWa/YgOI4GaHX2RODEGw==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.9.tgz", + "integrity": "sha512-+8MlijXbYbnCoRuexWuPIYxigh9ZsU/X5iXA7mzFBKWzRb7t33uibGiPl1VIF2RG3oLLxeSySewH98R3LAd9Vg==", "license": "MIT", "dependencies": { "@stencil/core": "^4.44.2", @@ -9211,4 +9211,4 @@ "license": "MIT" } } -} \ No newline at end of file +} diff --git a/packages/docs/package-lock.json b/packages/docs/package-lock.json index 43e72c5424b..86294ffba1a 100644 --- a/packages/docs/package-lock.json +++ b/packages/docs/package-lock.json @@ -10,4 +10,4 @@ "license": "MIT" } } -} \ No newline at end of file +} diff --git a/packages/react-router/package-lock.json b/packages/react-router/package-lock.json index 63d11df14db..13246f43bfb 100644 --- a/packages/react-router/package-lock.json +++ b/packages/react-router/package-lock.json @@ -257,9 +257,9 @@ } }, "node_modules/@ionic/core": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.8.tgz", - "integrity": "sha512-0KmlxdVWb8dryWTyX69bnH54764jiQnqxX9RHZE313xpyunxDl8UfdxpLiKRWKocawOWa/YgOI4GaHX2RODEGw==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.9.tgz", + "integrity": "sha512-+8MlijXbYbnCoRuexWuPIYxigh9ZsU/X5iXA7mzFBKWzRb7t33uibGiPl1VIF2RG3oLLxeSySewH98R3LAd9Vg==", "license": "MIT", "dependencies": { "@stencil/core": "^4.44.2", @@ -297,12 +297,12 @@ } }, "node_modules/@ionic/react": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@ionic/react/-/react-9.0.8.tgz", - "integrity": "sha512-8N9l+SA2VdcpX6lT9C6bH7brdHfekdbbRmjvXOU4kl86KHdVeqirkkt63iyLWm6VoEKLc2OuAfy4tjUSnlv0rw==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@ionic/react/-/react-9.0.9.tgz", + "integrity": "sha512-bnfGMECWnaFNIuljq1Z18YmQUu8Tk9UhPPLqkbKBmzO9MTKPqOGTrcKWOuYaPGHELTQLVENYHt43igpIhWpHVg==", "license": "MIT", "dependencies": { - "@ionic/core": "9.0.8", + "@ionic/core": "9.0.9", "@stencil/react-output-target": "^1.6.2", "ionicons": "^8.0.13", "tslib": "*" @@ -4632,4 +4632,4 @@ } } } -} \ No newline at end of file +} diff --git a/packages/react/package-lock.json b/packages/react/package-lock.json index 38750892c71..da52bb1b7a7 100644 --- a/packages/react/package-lock.json +++ b/packages/react/package-lock.json @@ -891,9 +891,9 @@ } }, "node_modules/@ionic/core": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.8.tgz", - "integrity": "sha512-0KmlxdVWb8dryWTyX69bnH54764jiQnqxX9RHZE313xpyunxDl8UfdxpLiKRWKocawOWa/YgOI4GaHX2RODEGw==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.9.tgz", + "integrity": "sha512-+8MlijXbYbnCoRuexWuPIYxigh9ZsU/X5iXA7mzFBKWzRb7t33uibGiPl1VIF2RG3oLLxeSySewH98R3LAd9Vg==", "license": "MIT", "dependencies": { "@stencil/core": "^4.44.2", @@ -8837,4 +8837,4 @@ } } } -} \ No newline at end of file +} diff --git a/packages/vue-router/package-lock.json b/packages/vue-router/package-lock.json index b9edfd1a352..8990d894e7f 100644 --- a/packages/vue-router/package-lock.json +++ b/packages/vue-router/package-lock.json @@ -342,9 +342,9 @@ } }, "node_modules/@ionic/core": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.8.tgz", - "integrity": "sha512-0KmlxdVWb8dryWTyX69bnH54764jiQnqxX9RHZE313xpyunxDl8UfdxpLiKRWKocawOWa/YgOI4GaHX2RODEGw==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.9.tgz", + "integrity": "sha512-+8MlijXbYbnCoRuexWuPIYxigh9ZsU/X5iXA7mzFBKWzRb7t33uibGiPl1VIF2RG3oLLxeSySewH98R3LAd9Vg==", "license": "MIT", "dependencies": { "@stencil/core": "^4.44.2", @@ -382,12 +382,12 @@ } }, "node_modules/@ionic/vue": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@ionic/vue/-/vue-9.0.8.tgz", - "integrity": "sha512-SpHWvKU2a3t2x03cFQt5mLNBbxuPsNtwiJvOhzfZdmEfnyj7bN0zJkevwToiGyYdmBh7kWuuq4wrUohdtzEwgA==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@ionic/vue/-/vue-9.0.9.tgz", + "integrity": "sha512-ASaDjaWfV/Rwt5GyNNOsvKD49VddzoxGi3rl5y/p7lN1+1JB6pkFiTK4BphmPLBI+zVO3S61PO0rVPl3GeXi5Q==", "license": "MIT", "dependencies": { - "@ionic/core": "9.0.8", + "@ionic/core": "9.0.9", "@stencil/vue-output-target": "0.14.2", "ionicons": "^8.0.13" } @@ -5146,4 +5146,4 @@ } } } -} \ No newline at end of file +} diff --git a/packages/vue/package-lock.json b/packages/vue/package-lock.json index 87909016178..94354eac003 100644 --- a/packages/vue/package-lock.json +++ b/packages/vue/package-lock.json @@ -346,9 +346,9 @@ } }, "node_modules/@ionic/core": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.8.tgz", - "integrity": "sha512-0KmlxdVWb8dryWTyX69bnH54764jiQnqxX9RHZE313xpyunxDl8UfdxpLiKRWKocawOWa/YgOI4GaHX2RODEGw==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@ionic/core/-/core-9.0.9.tgz", + "integrity": "sha512-+8MlijXbYbnCoRuexWuPIYxigh9ZsU/X5iXA7mzFBKWzRb7t33uibGiPl1VIF2RG3oLLxeSySewH98R3LAd9Vg==", "license": "MIT", "dependencies": { "@stencil/core": "^4.44.2", @@ -5234,4 +5234,4 @@ } } } -} \ No newline at end of file +}