diff --git a/demo/src/dom-atomic-swap.spec.ts b/demo/src/dom-atomic-swap.spec.ts
new file mode 100644
index 000000000..be9c1601b
--- /dev/null
+++ b/demo/src/dom-atomic-swap.spec.ts
@@ -0,0 +1,88 @@
+import { expect, test } from 'vitest';
+import {
+ isAtomicSwap,
+ isAtomicSwapDuration,
+ isAtomicSwapHandoff,
+ setVerticalBarsEnteringPage,
+ visible,
+} from '../../packages/capacitor-native-ui/src/native/shared/dom';
+
+test.each(['data-shell-handoff', 'class'] as const)('isAtomicSwap via %s matches the element and ancestors', (attribute) => {
+ document.body.innerHTML = '
';
+ const parent = document.getElementById('parent')!;
+ const segment = document.getElementById('segment')!;
+ const other = document.getElementById('other')!;
+ if (attribute === 'class') segment.className = 'ios-theme-shell-handoff-swap';
+ else segment.setAttribute('data-shell-handoff', 'swap');
+ expect(isAtomicSwap(segment)).toBe(true);
+ expect(isAtomicSwap(other)).toBe(false);
+ if (attribute === 'class') {
+ segment.className = '';
+ parent.className = 'ios-theme-shell-handoff-swap';
+ } else {
+ segment.removeAttribute('data-shell-handoff');
+ parent.setAttribute('data-shell-handoff', 'swap');
+ }
+ expect(isAtomicSwap(segment)).toBe(true);
+ expect(isAtomicSwap(other)).toBe(true);
+ document.body.innerHTML = '';
+});
+
+test('unknown data-shell-handoff values do not opt in', () => {
+ document.body.innerHTML = '';
+ expect(isAtomicSwap(document.querySelector('ion-segment')!)).toBe(false);
+ document.body.innerHTML = '';
+});
+
+test('isAtomicSwapHandoff requires every changed source to be atomic', () => {
+ document.body.innerHTML = `
+
+ `;
+ const atomic = document.getElementById('atomic')!;
+ const back = document.getElementById('back')!;
+ expect(isAtomicSwapHandoff([])).toBe(false);
+ expect(isAtomicSwapHandoff([atomic])).toBe(true);
+ expect(isAtomicSwapHandoff([atomic, back])).toBe(false);
+ document.body.innerHTML = '';
+});
+
+test('isAtomicSwapDuration is 0 when every added source is atomic', () => {
+ document.body.innerHTML = `
+
+ `;
+ const atomic = document.getElementById('atomic')!;
+ const back = document.getElementById('back')!;
+ expect(isAtomicSwapDuration([back], [atomic])).toBe(true);
+ expect(isAtomicSwapDuration([atomic], [])).toBe(true);
+ expect(isAtomicSwapDuration([back], [])).toBe(false);
+ expect(isAtomicSwapDuration([], [back])).toBe(false);
+ document.body.innerHTML = '';
+});
+
+test('entering swap stays measurable while the header is still opted out', () => {
+ document.body.innerHTML = `
+
+
+
+
+
`;
+ const page = document.querySelector('.ion-page') as HTMLElement;
+ const segment = document.getElementById('segment')!;
+ document.documentElement.style.opacity = '1';
+ document.body.style.opacity = '1';
+ segment.getBoundingClientRect = () => ({
+ width: 200,
+ height: 40,
+ left: 8,
+ top: 8,
+ right: 208,
+ bottom: 48,
+ x: 8,
+ y: 8,
+ toJSON: () => ({}),
+ });
+ setVerticalBarsEnteringPage(page, true);
+ expect(visible(segment, true)).toBe(true);
+ setVerticalBarsEnteringPage(page, false);
+ document.body.innerHTML = '';
+});
diff --git a/demo/src/native-ui-shell-transition.spec.ts b/demo/src/native-ui-shell-transition.spec.ts
index ccb180751..4bc6e577f 100644
--- a/demo/src/native-ui-shell-transition.spec.ts
+++ b/demo/src/native-ui-shell-transition.spec.ts
@@ -57,10 +57,10 @@ const fixture = () => {
return { animation, calls, ready, finish: (step: 0 | 1) => finish(step, animation) };
};
-test('normal transition waits for native suspension', async () => {
+test('play starts before native suspension settles', async () => {
const { animation, calls, ready } = fixture();
const played = animation.play();
- expect(calls).toEqual([]);
+ expect(calls).toEqual(['play']);
ready();
await played;
expect(calls).toEqual(['play', 'resume:false']);
@@ -87,7 +87,7 @@ test('destroy during bridge preparation cannot start an abandoned transition', a
animation.destroy();
ready();
await played;
- expect(calls).toEqual(['destroy', 'resume:true']);
+ expect(calls).toEqual(['play', 'destroy', 'resume:true']);
});
test('without native enablement the animation remains untouched', () => {
diff --git a/packages/capacitor-native-ui/docs/native-ui-shell.md b/packages/capacitor-native-ui/docs/native-ui-shell.md
index d5a6d7e58..a88d84103 100644
--- a/packages/capacitor-native-ui/docs/native-ui-shell.md
+++ b/packages/capacitor-native-ui/docs/native-ui-shell.md
@@ -135,6 +135,8 @@ Ordinary native/Web handoffs share a 180ms opacity crossfade, including Vertical
Tab switches skip the Web/native crossfade so a retiring UIKit snapshot cannot linger over the next tab. Detection uses the router URL versus the still-selected tab on `ionViewWillLeave` (and vanilla `ionTabsWillChange` / `ionTabsDidChange` DOM events). Stack pushes and pops keep the normal 180ms handoff.
+Opt in to an atomic swap with `data-shell-handoff="swap"` (or the `ios-theme-shell-handoff-swap` class) on controls whose native chrome differs between two stacked pages, for example a header segment that becomes a back button. Native chrome is measured at rest on the entering page, the shell hands off with duration 0 instead of the crossfade, and a page that is leaving is not re-projected. Mixed sets keep the 180ms handoff. Without the attribute nothing changes. `withNativeUIShellTransition` starts the suspend while the page animation plays instead of before it, so native chrome is not retired while the page is still at rest; wrap custom page builders (for example a nested outlet) the same way.
+
Standard Ionic overlays suspend ordinary native projection until dismissal. Vertical Bars use a separate policy:
| Surface or condition | Vertical Bars behavior |
diff --git a/packages/capacitor-native-ui/src/native/components/index.ts b/packages/capacitor-native-ui/src/native/components/index.ts
index fae0f00e2..2722d0db2 100644
--- a/packages/capacitor-native-ui/src/native/components/index.ts
+++ b/packages/capacitor-native-ui/src/native/components/index.ts
@@ -6,7 +6,7 @@ import * as menuButton from './ion-menu-button';
import * as tabBar from './ion-tab-bar';
import * as segment from './ion-segment';
import * as fab from './ion-fab';
-import { isVerticalBarsSource, visible, withoutPrehide } from '../shared/dom';
+import { isVerticalBarsSource, swapEnteringPage, visible, withoutPageTransform, withoutPrehide } from '../shared/dom';
import type { Candidate, Identify } from '../shared/candidate';
// Static composition only. Each component declares its own tag, discovery and reader.
@@ -32,14 +32,16 @@ export const isVerticalBarsCandidate = isVerticalBarsSource;
export const readCandidate = (element: HTMLElement, id: Identify, options: VerticalControlAreaOptions = {}): Candidate | undefined => {
const verticalBars = isVerticalBarsCandidate(element);
+ const swapEnter = !!swapEnteringPage(element);
if (
- (!element.classList.contains('ios') && !verticalBars) ||
- !visible(element, verticalBars) ||
+ (!element.classList.contains('ios') && !verticalBars && !swapEnter) ||
+ !visible(element, verticalBars || swapEnter) ||
element.closest('ion-popover') ||
(element.closest('ion-modal') && !verticalBars)
)
return;
if (element.contains(element.ownerDocument.activeElement)) return;
// Read icon and control geometry before restoring prehide, which can collapse the toolbar.
- return withoutPrehide(element, () => components.find((component) => component.tag === element.localName)?.read(element, id, options));
+ const read = () => components.find((component) => component.tag === element.localName)?.read(element, id, options);
+ return withoutPrehide(element, () => (swapEnter ? withoutPageTransform(element, read) : read()));
};
diff --git a/packages/capacitor-native-ui/src/native/runtime.ts b/packages/capacitor-native-ui/src/native/runtime.ts
index 9de1433f7..2d5c7ac4f 100644
--- a/packages/capacitor-native-ui/src/native/runtime.ts
+++ b/packages/capacitor-native-ui/src/native/runtime.ts
@@ -28,11 +28,16 @@ import {
createVerticalBarsPageState,
isVerticalBarsSource,
preferredVerticalBarsBack,
+ isAtomicSwap,
+ isAtomicSwapDuration,
marker,
+ measureMarker,
+ swapEnteringPage,
syncToolbarText,
toolbarTextMarker,
prehideOnlyMutation,
rejectedClass,
+ transformMeasurePages,
unprojected,
} from './shared/dom';
import { createIconRenderer } from './shared/icons';
@@ -76,6 +81,7 @@ export const createRuntime = async (
const verticalBarsMembers = new Set();
const suspended = new Set();
const pages = new Set();
+ const leftPages = new WeakSet();
const verticalBarsPages = createVerticalBarsPageState();
const presented = new Set();
const railPopovers = new WeakSet();
@@ -95,6 +101,8 @@ export const createRuntime = async (
let viewport = `${win.innerWidth}:${win.innerHeight}`;
let forceRefresh = false;
let dirty = false;
+ // A swap retire restores its sources first, so a retry sees no removed sources; remember it until sent.
+ let swapPending = false;
let pending = false;
let stopped = false;
let destruction: Promise | undefined;
@@ -139,6 +147,7 @@ export const createRuntime = async (
const hidden = `[${marker}]:not([${fadeMarker}])`;
style.textContent = `${hidden}, ${hidden} *, ${hidden}::before, ${hidden}::after, ${hidden}::part(native) { visibility: hidden !important; }
[${marker}], [${marker}] * { pointer-events: none !important; }
+ [${measureMarker}], [${measureMarker}] * { pointer-events: auto !important; }
/* Ionic disables the covered page while a menu is open. VerticalBars rail
controls remain outside that page; zero specificity preserves any
pointer-events rule supplied by the application itself. */
@@ -153,7 +162,12 @@ export const createRuntime = async (
lastSnapshot = '';
search.release(element);
element.removeAttribute(marker);
- if (!stopped) crossfade.play(element, false, handoffInstant || (isVerticalBarsSource(element) && !element.matches('ion-tab-bar')));
+ if (!stopped)
+ crossfade.play(
+ element,
+ false,
+ isAtomicSwap(element) || (isVerticalBarsSource(element) ? !element.matches('ion-tab-bar') : handoffInstant),
+ );
if (element.getAttribute('aria-hidden') === 'true') {
const previous = sources.get(element);
if (previous == null) element.removeAttribute('aria-hidden');
@@ -242,12 +256,25 @@ export const createRuntime = async (
await fail(error);
}
};
- const blocked = (element: HTMLElement) =>
- verticalBarsPages.isDeparted(element) ||
- (!isVerticalBarsSource(element) &&
- (Array.from(suspended).some((scopes) => scopes.some((scope) => scope.contains(element))) ||
- Array.from(pages).some((scope) => scope.contains(element)) ||
- Array.from(moving.keys()).some((surface) => surface.contains(element))));
+ const pageOf = (element: HTMLElement) => element.closest('.ion-page');
+ const underSuspended = (element: HTMLElement) => Array.from(suspended).some((scopes) => scopes.some((scope) => scope.contains(element)));
+ const underMovingSurface = (element: HTMLElement) =>
+ Array.from(moving.keys()).some((surface) => surface !== element && surface.contains(element));
+ const blockedReason = (element: HTMLElement): string | undefined => {
+ if (verticalBarsPages.isDeparted(element) && !(sources.has(element) && isAtomicSwap(element))) return 'departed';
+ const page = pageOf(element);
+ if (page?.classList.contains('ion-page-hidden') && !sources.has(element) && !swapEnteringPage(element)) return 'departed';
+ if (page && leftPages.has(page) && !sources.has(element) && !swapEnteringPage(element)) return 'departed';
+ if (isVerticalBarsSource(element) && !isAtomicSwap(element)) return undefined;
+ // Leaving swap retires so Web can slide. Entering swap stays native at rest.
+ if (underSuspended(element) && !swapEnteringPage(element)) return 'suspend';
+ if (Array.from(pages).some((scope) => scope.contains(element)) && !isAtomicSwap(element)) return 'pages';
+ if (!sources.has(element) && underMovingSurface(element)) return 'motion';
+ return undefined;
+ };
+ // Ancestor CSS motion must not retire already-projected chrome. Suspend and
+ // transitioning pages still restore so native overlays do not sit on the next page.
+ const blocked = (element: HTMLElement) => !!blockedReason(element);
const painted = () => new Promise((resolve) => win.requestAnimationFrame(() => win.requestAnimationFrame(() => resolve())));
const overlayOpen = (includeMenu = true, allowModal = false, ignoredSelector?: string) => {
const modal = topModal(doc);
@@ -292,7 +319,7 @@ export const createRuntime = async (
.filter(
(element) =>
inVerticalBarsSurface(element) &&
- !verticalBarsPages.isDeparted(element) &&
+ !(verticalBarsPages.isDeparted(element) && !(sources.has(element) && isAtomicSwap(element))) &&
(!blocked(element) || (overlayVisible && isVerticalBarsSource(element))),
)
.map(readEnabledCandidate)
@@ -410,6 +437,9 @@ export const createRuntime = async (
if (stopped || dirty || (retentions.size && !manualSuspensions.size)) return;
const retained = new Set(candidates.flatMap(candidateSources));
const removed = Array.from(sources.keys()).filter((element) => !retained.has(element));
+ const added = candidates.flatMap(candidateSources).filter((element) => !sources.has(element));
+ const atomicNow = isAtomicSwapDuration(removed, added);
+ if (atomicNow) swapPending = true;
if (removed.some(isVerticalBarsSource)) {
actions.forEach(forgetVerticalBarFrame);
try {
@@ -451,7 +481,8 @@ export const createRuntime = async (
win.dispatchEvent(new CustomEvent('nativeUIShellVerticalBarsRelease', { detail: { instant: handoffInstant } }));
// The outgoing tab is no longer visible, so waiting two frames only leaves its
// native snapshot over the destination. Stack transitions still need the paint.
- if (!handoffInstant) {
+ // Atomic swap must not wait: the delay splits the retire into a later 180ms fade.
+ if (!handoffInstant && !atomicNow) {
await painted();
if (stopped || dirty) return;
}
@@ -475,8 +506,16 @@ export const createRuntime = async (
controls: candidates.map((candidate) => candidate.control),
};
const serialized = JSON.stringify(data);
- if (serialized === lastSnapshot && !forceRefresh) return;
- const snapshot: ShellSnapshot = { ...data, revision: ++revision, transitionDuration: crossfade.duration(handoffInstant) };
+ if (serialized === lastSnapshot && !forceRefresh) {
+ swapPending = false;
+ return;
+ }
+ const snapshot: ShellSnapshot = {
+ ...data,
+ revision: ++revision,
+ transitionDuration: crossfade.duration(handoffInstant || swapPending || atomicNow),
+ };
+ swapPending = false;
// A native visibility notification during this update must survive its ack.
forceRefresh = false;
updates++;
@@ -548,7 +587,11 @@ export const createRuntime = async (
const newlyProjected = !sources.has(element) || !element.hasAttribute(marker);
if (!sources.has(element)) {
sources.set(element, element.getAttribute('aria-hidden'));
- crossfade.play(element, true, handoffInstant || (isVerticalBarsSource(element) && !element.matches('ion-tab-bar')));
+ crossfade.play(
+ element,
+ true,
+ handoffInstant || isAtomicSwap(element) || (isVerticalBarsSource(element) && !element.matches('ion-tab-bar')),
+ );
}
element.setAttribute(marker, '');
element.setAttribute('aria-hidden', 'true');
@@ -604,7 +647,11 @@ export const createRuntime = async (
record.attributeName !== toolbarTextMarker &&
record.attributeName !== fadeMarker &&
!prehideOnlyMutation(record) &&
- !(record.attributeName === 'style' && measuringPointerPages.has(record.target as HTMLElement)) &&
+ !(
+ record.attributeName === 'style' &&
+ (measuringPointerPages.has(record.target as HTMLElement) || transformMeasurePages.has(record.target as HTMLElement))
+ ) &&
+ record.attributeName !== measureMarker &&
!(
record.attributeName === 'aria-hidden' &&
sources.has(record.target as HTMLElement) &&
@@ -625,13 +672,20 @@ export const createRuntime = async (
.forEach((binding) => search.retire(binding));
// Angular updates the URL before willLeave, but keeps ion-tab-bar.selectedTab on the
// leaving tab until DidChange. A mismatch means this leave is a tab switch, not a stack push.
- if (event.type === LIFECYCLE_WILL_LEAVE) beginTabSwitchHandoffIfNeeded(page);
+ if (event.type === LIFECYCLE_WILL_LEAVE) {
+ leftPages.add(page);
+ beginTabSwitchHandoffIfNeeded(page);
+ } else if (event.type === LIFECYCLE_WILL_ENTER) {
+ leftPages.delete(page);
+ }
pages.add(page);
schedule();
};
const pageDid: EventListener = (event) => {
const page = event.target as HTMLElement;
verticalBarsPages.lifecycle(event);
+ if (event.type === LIFECYCLE_DID_ENTER) leftPages.delete(page);
+ else if (event.type === LIFECYCLE_DID_LEAVE) leftPages.add(page);
pages.delete(page);
schedule();
if (tabSwitchHandoff && pages.size === 0) endTabSwitchHandoff();
@@ -696,6 +750,8 @@ export const createRuntime = async (
const leaving = event.target as HTMLElement;
const entering = (event as CustomEvent<{ entering?: HTMLElement }>).detail?.entering;
verticalBarsPages.cancel(entering, leaving);
+ leftPages.delete(leaving);
+ if (entering) leftPages.add(entering);
pages.delete(leaving);
if (entering) pages.delete(entering);
schedule();
diff --git a/packages/capacitor-native-ui/src/native/shared/dom.ts b/packages/capacitor-native-ui/src/native/shared/dom.ts
index 2162447fb..ae094a927 100644
--- a/packages/capacitor-native-ui/src/native/shared/dom.ts
+++ b/packages/capacitor-native-ui/src/native/shared/dom.ts
@@ -4,6 +4,8 @@ import { modalUsesVerticalBars } from './modal';
import type { Frame } from '../definitions';
export const marker = 'data-native-ui-shell';
+/** Measure projected chrome without clearing `marker` (that paints Web for a frame). */
+export const measureMarker = 'data-native-ui-shell-measure';
// Scoped runtimes (Stencil slot emulation) patch child accessors on upgraded
// elements and may report none before hydration; the prototype getter still
// reports the real light DOM.
@@ -27,6 +29,35 @@ export const prehideOnlyMutation = (record: MutationRecord): boolean => {
return withoutPrehide(record.oldValue) === withoutPrehide((record.target as Element).getAttribute('class') ?? '');
};
+/** Swap chrome on a page between willEnter and didEnter (whole slide, not only ion-page-invisible). */
+export const swapEnteringPage = (element: HTMLElement): HTMLElement | undefined => {
+ if (!isAtomicSwap(element)) return undefined;
+ const page = element.closest('.ion-page');
+ return page && enteringPages.has(page) ? page : undefined;
+};
+
+/** Pages whose transform is briefly cleared to measure swap chrome at rest. */
+export const transformMeasurePages = new WeakSet();
+
+/** Read geometry as if the entering page had already landed (native overlays do not slide). */
+export const withoutPageTransform = (element: HTMLElement, read: () => T): T => {
+ const page = element.closest('.ion-page');
+ if (!page) return read();
+ const previous = page.style.getPropertyValue('transform');
+ const priority = page.style.getPropertyPriority('transform');
+ const hadStyle = page.hasAttribute('style');
+ transformMeasurePages.add(page);
+ page.style.setProperty('transform', 'none', 'important');
+ try {
+ return read();
+ } finally {
+ if (previous) page.style.setProperty('transform', previous, priority);
+ else page.style.removeProperty('transform');
+ if (!hadStyle && !page.style.length) page.removeAttribute('style');
+ transformMeasurePages.delete(page);
+ }
+};
+
export const withoutPrehide = (element: HTMLElement, read: () => T): T => {
const changed: HTMLElement[] = [];
for (let current: HTMLElement | null = element; current; current = current.parentElement) {
@@ -90,6 +121,7 @@ export const createVerticalBarsPageState = () => {
};
const disabledButtonGroup = 'ion-buttons:is(.ios-theme-disabled, .ios26-disabled)';
const shellDisabledSelector = '.ios-theme-shell-disabled, [data-shell="disabled"]';
+const atomicSwapSelector = '.ios-theme-shell-handoff-swap, [data-shell-handoff="swap"]';
export const isDisabledButtonGroupChild = (element: HTMLElement): boolean =>
element.matches('ion-button') && element.parentElement?.matches(disabledButtonGroup) === true;
@@ -140,12 +172,23 @@ const excludedBy = (element: HTMLElement, selector: string): boolean => {
export const isPermanentlyExcluded = (element: HTMLElement): boolean => excludedBy(element, permanentlyExcluded);
export const isExcluded = (element: HTMLElement, enteringPage?: HTMLElement): boolean =>
- excludedBy(element, `${permanentlyExcluded}, .ion-page-hidden`) || (!!element.closest('.ion-page-invisible') && !enteringPage);
+ excludedBy(element, permanentlyExcluded) ||
+ (!enteringPage && excludedBy(element, '.ion-page-hidden')) ||
+ (!!element.closest('.ion-page-invisible') && !enteringPage);
// A shared native surface must not cover an opted-out descendant either.
export const isShellDisabled = (element: Element): boolean =>
!!element.closest(shellDisabledSelector) || !!element.querySelector(shellDisabledSelector);
+/** Instant Web/native swap on stack push/pop; does not skip the painted wait. */
+export const isAtomicSwap = (element: Element): boolean => !!element.closest(atomicSwapSelector);
+
+export const isAtomicSwapHandoff = (changed: readonly Element[]): boolean => changed.length > 0 && changed.every(isAtomicSwap);
+
+/** Native fade duration is 0 when every added source is atomic, or a retire-only sync is all atomic. */
+export const isAtomicSwapDuration = (removed: readonly Element[], added: readonly Element[]): boolean =>
+ added.length ? added.every(isAtomicSwap) : isAtomicSwapHandoff(removed);
+
export const isVerticalBarsToolbarActionShape = (element: HTMLElement): boolean =>
element.matches('ion-menu-button') ||
(element.matches('ion-button') && !!element.querySelector('ion-icon[slot="icon-only"], svg[slot="icon-only"]'));
@@ -228,32 +271,36 @@ export const unprojected = (elements: Iterable, read: () => T):
const list = Array.from(elements);
const hidden = list.filter((element) => element.hasAttribute(marker));
const webHidden = list.filter((element) => element.hasAttribute('data-native-ui-web-source'));
- hidden.forEach((element) => element.removeAttribute(marker));
+ hidden.forEach((element) => element.setAttribute(measureMarker, ''));
webHidden.forEach((element) => element.removeAttribute('data-native-ui-web-source'));
try {
return read();
} finally {
- hidden.forEach((element) => element.setAttribute(marker, ''));
+ hidden.forEach((element) => element.removeAttribute(measureMarker));
webHidden.forEach((element) => element.setAttribute('data-native-ui-web-source', ''));
}
};
const readVisible = (element: HTMLElement, allowOutsideViewport: boolean): boolean => {
- const enteringPage = isVerticalBarsSource(element) ? verticalBarsEnteringPage(element) : undefined;
- if (!element.isConnected || isExcluded(element, enteringPage) || isShellDisabled(element)) return false;
+ const enteringPage = isVerticalBarsSource(element) ? verticalBarsEnteringPage(element) : swapEnteringPage(element);
+ const measuring = !!element.closest(`[${measureMarker}]`);
+ if (!element.isConnected || isExcluded(element, enteringPage) || (isShellDisabled(element) && !enteringPage)) return false;
for (let current: HTMLElement | null = element; current; current = current.parentElement) {
const style = getComputedStyle(current);
+ const onEnteringPage = current === enteringPage || !!enteringPage?.contains(current);
if (
style.display === 'none' ||
- style.visibility !== 'visible' ||
- (Number(style.opacity) === 0 && !current.hasAttribute(fadeMarker) && current !== enteringPage)
+ (!measuring && style.visibility !== 'visible' && !onEnteringPage) ||
+ (!measuring && Number(style.opacity) === 0 && !current.hasAttribute(fadeMarker) && !onEnteringPage)
)
return false;
// Ordinary controls on moving/collapsing/custom transformed surfaces stay in Web coordinates.
// VerticalBars rail controls are placed independently of their Web coordinates and must remain
// owned while Ionic transforms the content behind an open menu.
+ // Swap chrome on an entering page is measured at rest; skip the sliding transform.
if (
!allowOutsideViewport &&
+ !enteringPage &&
style.transform !== 'none' &&
!new DOMMatrixReadOnly(style.transform).isIdentity &&
current !== element &&
@@ -265,7 +312,9 @@ const readVisible = (element: HTMLElement, allowOutsideViewport: boolean): boole
return (
rect.width > 0 &&
rect.height > 0 &&
- (allowOutsideViewport || (rect.left >= -1 && rect.top >= -1 && rect.right <= innerWidth + 1 && rect.bottom <= innerHeight + 1))
+ (allowOutsideViewport ||
+ !!enteringPage ||
+ (rect.left >= -1 && rect.top >= -1 && rect.right <= innerWidth + 1 && rect.bottom <= innerHeight + 1))
);
};
diff --git a/packages/theme-ios27/src/transition/native-ui.ts b/packages/theme-ios27/src/transition/native-ui.ts
index 4898ba90c..bc89054e3 100644
--- a/packages/theme-ios27/src/transition/native-ui.ts
+++ b/packages/theme-ios27/src/transition/native-ui.ts
@@ -56,11 +56,14 @@ export const connectNativeUIShellTransition = (animation: Animation, entering: H
};
animation.onFinish((step) => finish(step === 0));
animation.play = async (options) => {
- await prepare();
+ // Overlap suspend with the slide. Awaiting it first retires native chrome while the page is still
+ // at rest, which flashes Web for a frame before the slide starts.
+ const ready = prepare();
if (disposed) return;
try {
await play(options);
} finally {
+ await ready;
finish();
}
};