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(); } };