diff --git a/demo/e2e/native-ui.spec.ts b/demo/e2e/native-ui.spec.ts index 65b925c8b..e6aeb7a04 100644 --- a/demo/e2e/native-ui.spec.ts +++ b/demo/e2e/native-ui.spec.ts @@ -3,12 +3,18 @@ import type { Page } from '@playwright/test'; import { compile, NodePackageImporter } from 'sass'; import { resolve } from 'node:path'; import * as overlayTypes from '../src/app/overlay-types'; -import type { NativeUIControl, NativeUIItem, NativeUISnapshot } from '../../packages/capacitor-native-ui/src/native/definitions'; +import type { + NativeUIControl, + NativeUIItem, + NativeUISnapshot, + NativeUIPlugin, +} from '../../packages/capacitor-native-ui/src/native/definitions'; import type { NativeUIMockCore, TestAppElement } from './native-ui-mock'; const importer = new NodePackageImporter(resolve(__dirname, '../../')); interface NativeUIMock extends NativeUIMockCore { + update: NativeUIPlugin['update']; toolbarProgress: { id: number; progress: number }[]; delay: number; hang: boolean; @@ -1676,7 +1682,8 @@ for (const nativeEdge of [null, 'trailing'] as const) { await expect(group).toHaveAttribute('data-native-ui-projected', ''); await refresh.evaluate((element) => element.remove()); await expect(group).not.toHaveAttribute('data-native-ui-projected'); - await expect(github.locator('button')).toHaveCSS('visibility', 'visible'); + // A one-item group becomes an independent native button, not a Web handoff. + await expect(github).toHaveAttribute('data-native-ui-projected', ''); }); } @@ -2999,6 +3006,224 @@ test('reduced motion hands off without a crossfade', async ({ page }) => { await expect(segment).toHaveCSS('opacity', '1'); }); +test('Duo hides the entering search FAB before native acknowledgement and restores rejected search', async ({ page }) => { + await mockNative(page); + await page.route('https://picsum.photos/**', (route) => route.abort()); + await page.goto('/main/index'); + await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars')); + await expect(page.locator('ion-tab-bar')).toHaveAttribute('data-native-ui-projected', ''); + const fab = page.locator('app-album-page ion-fab'); + for (let visit = 0; visit < 2; visit++) { + await page.evaluate(() => { + Capacitor.registerPlugin('NativeUI').delay = 150; + const evidence: number[] = []; + Object.assign(window, { enteringFabFrames: evidence }); + const sample = () => { + const fab = document.querySelector('app-album-page ion-fab-button'); + if (fab && !fab.closest('.ion-page-hidden, .ion-page-invisible') && getComputedStyle(fab).visibility === 'visible') + evidence.push(performance.now()); + if (!document.querySelector('app-album-page ion-fab[data-native-ui-projected]')) requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + }); + await activate(page, 'Library'); + await expect(fab).toHaveAttribute('data-native-ui-projected', ''); + expect(await page.evaluate(() => (window as Window & { enteringFabFrames: number[] }).enteringFabFrames)).toEqual([]); + await activate(page, 'Index'); + await expect(page).toHaveURL(/\/main\/index/); + await expect(fab).not.toHaveAttribute('data-native-ui-projected'); + } + await page.evaluate(() => { + Capacitor.registerPlugin('NativeUI').rejectAllSearch = true; + }); + await activate(page, 'Library'); + await expect(fab).not.toHaveClass(/native-ui-prehidden/); + await expect(fab).not.toHaveAttribute('data-native-ui-projected'); +}); + +for (const edge of [null, 'trailing'] as const) { + test(`tab search keeps Web trigger hidden while native search remains available (${edge ?? 'horizontal'})`, async ({ page }) => { + await page.setViewportSize({ width: 402, height: 874 }); + await mockNative(page, false, edge); + await page.route('https://picsum.photos/**', (route) => route.abort()); + await page.goto('/main/album'); + if (edge) await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars')); + const fab = page.locator('app-album-page ion-fab'); + await expect(fab).toHaveAttribute('data-native-ui-projected', ''); + await fab.evaluate((element) => { + const evidence: string[] = []; + Object.assign(window, { searchOwnershipGaps: evidence }); + element.addEventListener('nativeUIChange', () => { + const state = Capacitor.registerPlugin('NativeUI'); + if ( + !element.hasAttribute('data-native-ui-projected') && + !element.closest('.ion-page-hidden, .ion-page-invisible') && + state.updates.at(-1)?.controls.some((control) => control.search?.available) + ) { + evidence.push(location.pathname); + } + }); + Capacitor.registerPlugin('NativeUI').delay = 150; + }); + await page.evaluate(() => { + const decode = HTMLImageElement.prototype.decode; + let release!: () => void; + const gate = new Promise((resolve) => (release = resolve)); + HTMLImageElement.prototype.decode = async function () { + await decode.call(this); + if (this.src.startsWith('blob:')) await gate; + }; + Object.assign(window, { + releaseSearchDestinationIcons: () => { + HTMLImageElement.prototype.decode = decode; + release(); + }, + }); + }); + await activate(page, 'Index'); + await expect(page).toHaveURL(/\/main\/index/); + await expect + .poll(() => + page.evaluate(() => + Capacitor.registerPlugin('NativeUI') + .updates.at(-1) + ?.controls.some((control) => control.search?.available), + ), + ) + .toBe(false); + // A router may finish hiding the departing page after its lifecycle event. + // The normal read must not resurrect a Search already retired natively. + await page.locator('app-album-page').evaluate((element) => element.classList.remove('ion-page-hidden')); + await page.evaluate(() => (window as Window & { releaseSearchDestinationIcons(): void }).releaseSearchDestinationIcons()); + await expect(fab).not.toHaveAttribute('data-native-ui-projected'); + expect( + await page.evaluate(() => + Capacitor.registerPlugin('NativeUI') + .updates.at(-1) + ?.controls.some((control) => control.search?.available), + ), + ).toBe(false); + await page.locator('app-album-page').evaluate((element) => element.classList.add('ion-page-hidden')); + expect(await page.evaluate(() => (window as Window & { searchOwnershipGaps: string[] }).searchOwnershipGaps)).toEqual([]); + }); + + test(`tab switches retire old controls before destination icons finish (${edge ?? 'horizontal'})`, async ({ page }) => { + await page.setViewportSize({ width: 402, height: 874 }); + await mockNative(page, false, edge); + await page.route('https://picsum.photos/**', (route) => route.abort()); + await page.goto('/main/index/native-ui'); + if (edge) await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars')); + const segment = page.locator('app-native-ui ion-segment'); + await expect(segment).toHaveAttribute('data-native-ui-projected', ''); + const outgoing = await page.evaluate(() => { + const controls = Capacitor.registerPlugin('NativeUI').updates.at(-1)!.controls; + return controls.filter((control) => control.kind !== 'tab-bar').map((control) => control.id); + }); + await page.evaluate((ids) => { + const state = Capacitor.registerPlugin('NativeUI'); + const update = state.update.bind(state); + state.update = async (snapshot) => { + if (!snapshot.controls.some((control) => ids.includes(control.id))) { + const evidence = window as Window & { tabRetiredAt?: string }; + evidence.tabRetiredAt ??= location.pathname; + } + return update(snapshot); + }; + const decode = HTMLImageElement.prototype.decode; + let release!: () => void; + const gate = new Promise((resolve) => (release = resolve)); + HTMLImageElement.prototype.decode = async function () { + await decode.call(this); + if (this.src.startsWith('blob:')) await gate; + }; + Object.assign(window, { + releaseTabIcons: () => { + HTMLImageElement.prototype.decode = decode; + release(); + }, + }); + }, outgoing); + await activate(page, 'Library'); + await expect(page).toHaveURL(/\/main\/album/); + expect(await page.evaluate(() => (window as Window & { tabRetiredAt?: string }).tabRetiredAt)).toBe('/main/index/native-ui'); + await expect + .poll(() => + page.evaluate((ids) => { + const snapshot = Capacitor.registerPlugin('NativeUI').updates.at(-1)!; + return snapshot.controls.some((control) => ids.includes(control.id)); + }, outgoing), + ) + .toBe(false); + await expect + .poll(() => + page.evaluate(() => { + const snapshot = Capacitor.registerPlugin('NativeUI').updates.at(-1)!; + return snapshot.controls.some((control) => control.kind === 'tab-bar'); + }), + ) + .toBe(true); + await page.evaluate(() => (window as Window & { releaseTabIcons(): void }).releaseTabIcons()); + await activate(page, 'Index'); + await expect(segment).toHaveAttribute('data-native-ui-projected', ''); + await page.evaluate(() => { + Capacitor.registerPlugin('NativeUI').delay = 150; + }); + await activate(page, 'Library'); + await activate(page, 'Docs'); + await expect(page).toHaveURL(/\/main\/docs/); + await activate(page, 'Index'); + await expect(segment).toHaveAttribute('data-native-ui-projected', ''); + // A rejected click must return the unchanged page to native ownership. + await page.locator('ion-tab-button[tab="album"]').evaluate((button) => { + button.addEventListener('click', (event) => event.stopImmediatePropagation(), { capture: true, once: true }); + }); + await activate(page, 'Library'); + await expect(segment).not.toHaveAttribute('data-native-ui-projected'); + await expect(segment).toHaveAttribute('data-native-ui-projected', ''); + await expect(page).toHaveURL(/\/main\/index\/native-ui/); + }); +} + +for (const edge of [null, 'trailing'] as const) { + test(`rejected tab selection preserves the next stack transition (${edge ?? 'horizontal'})`, async ({ page }) => { + await page.setViewportSize({ width: 402, height: 874 }); + await mockNative(page, false, edge); + await page.goto('/main/index'); + if (edge) await page.locator('ion-app').evaluate((element) => element.classList.add('ios-theme-vertical-bars')); + const menu = page.locator('index-page > ion-header ion-buttons[slot="end"]'); + await expect(menu).toHaveAttribute('data-native-ui-projected', ''); + await page.locator('ion-tab-button[tab="album"]').evaluate((button) => { + button.addEventListener('click', (event) => event.stopImmediatePropagation(), { capture: true, once: true }); + }); + await activate(page, 'Library'); + await expect(menu).not.toHaveAttribute('data-native-ui-projected'); + await expect(menu).toHaveAttribute('data-native-ui-projected', ''); + await expect(page).toHaveURL(/\/main\/index/); + await page.getByText('native-ui', { exact: true }).click(); + if (edge) { + const depth = () => + page.evaluate(() => Capacitor.registerPlugin('NativeUI').updates.at(-1)?.verticalBarNavigation?.length); + await expect.poll(depth).toBe(2); + await page.goBack(); + await expect(page).toHaveURL(/\/main\/index/); + await expect.poll(depth).toBe(1); + return; + } + await expect + .poll(() => + page.evaluate(() => { + const state = Capacitor.registerPlugin('NativeUI'); + return ( + state.updates.some((snapshot) => + snapshot.toolbarTransition?.destination.some((control) => control.items.some((item) => item.accessibilityLabel === 'Save')), + ) && state.toolbarProgress.length > 0 + ); + }), + ) + .toBe(true); + }); +} + test('tab switches hand off without a crossfade', async ({ page }) => { await mockNative(page); await page.goto('/main/index/native-ui'); @@ -3182,7 +3407,7 @@ test('rejected search retries when tab content changes without resizing', async await page .locator('ion-tab-button ion-label') .first() - .evaluate((el) => (el.textContent = 'Index')); + .evaluate((el) => (el.textContent = 'Retry')); await expect(footer).toHaveAttribute('data-native-ui-projected', ''); expect(await page.locator('ion-tab-bar').boundingBox()).toEqual(before); }); diff --git a/packages/capacitor-native-ui/src/native/components/searchable-tabs.ts b/packages/capacitor-native-ui/src/native/components/searchable-tabs.ts index e46706cfc..06143234b 100644 --- a/packages/capacitor-native-ui/src/native/components/searchable-tabs.ts +++ b/packages/capacitor-native-ui/src/native/components/searchable-tabs.ts @@ -1,6 +1,6 @@ import { getNativeSearchBindings, requestNativeSearch } from '../../native-integration'; import type { NativeSearchBinding } from '../../native-integration'; -import { excluded, inFixedToolbar, isNativeUIDisabled, marker, text, visible, withoutPrehide } from '../shared/dom'; +import { excluded, inFixedToolbar, isNativeUIDisabled, marker, prehiddenClass, text, visible, withoutPrehide } from '../shared/dom'; import { iconSource } from '../shared/icons'; import type { Candidate } from '../shared/candidate'; import type { NativeUIItem, NativeUISearch, NativeUISearchEvent } from '../definitions'; @@ -24,6 +24,8 @@ interface SearchState { export const createSearchSupport = (doc: Document, id: (element: Element) => string, schedule: () => void) => { const states = new Map(); + const enteringTriggers = new Map(); + const preparedBindings = new WeakSet(); let keyboardVisible = false; let heldKeyboardResize: string | null = null; let keyboardResizeGeneration = 0; @@ -177,6 +179,32 @@ export const createSearchSupport = (doc: Document, id: (element: Element) => str } schedule(); }, + prepare(scope: ParentNode = doc) { + for (const binding of getNativeSearchBindings(doc)) { + if ( + (scope === doc && preparedBindings.has(binding)) || + !scope.contains(binding.trigger) || + !binding.trigger.closest('ion-app.ios-theme-vertical-bars') || + binding.trigger.closest('.ion-page-hidden') || + isNativeUIDisabled(binding.trigger) + ) + continue; + preparedBindings.add(binding); + const trigger = binding.trigger.closest('ion-fab') ?? binding.trigger; + if (trigger.hasAttribute(marker) || enteringTriggers.has(trigger)) continue; + // Registration / WillEnter runs before the first page paint. Keep the + // original FAB hidden until native eligibility and acknowledgement settle. + trigger.classList.add(prehiddenClass); + enteringTriggers.set(trigger, binding); + } + }, + settle(transitioning: (element: HTMLElement) => boolean) { + for (const [trigger, binding] of enteringTriggers) { + if (trigger.isConnected && !trigger.closest('.ion-page-hidden') && transitioning(binding.footer)) continue; + trigger.classList.remove(prehiddenClass); + enteringTriggers.delete(trigger); + } + }, keepSearchTabsVisible() { if (!keyboardVisible) return; for (const [binding, state] of states) { @@ -198,7 +226,6 @@ export const createSearchSupport = (doc: Document, id: (element: Element) => str } } const inactive: [Candidate, NativeUISearch][] = []; - const pageHidden = (element: HTMLElement) => !!element.closest('.ion-page-hidden, .ion-page-invisible'); const retainInactive = (candidate: Candidate, state?: SearchState, keepAvailable = false) => { // Keep NativeUISearchController across page transitions instead of demoting to UITabBar. // While the searchable page is only blocked mid-transition (not yet hidden), keep @@ -219,7 +246,12 @@ export const createSearchSupport = (doc: Document, id: (element: Element) => str const pageUnavailable = blocked(binding.footer) || !!binding.footer.closest(excluded); const existing = states.get(binding); if (pageUnavailable) { - retainInactive(candidate, existing, !!existing?.last?.available && !pageHidden(binding.footer)); + const keepAvailable = !!existing?.last?.available && !binding.footer.closest(excluded); + retainInactive(candidate, existing, keepAvailable); + // The retained native search still owns its Web sources until the page + // hides. Releasing only those sources briefly draws both search buttons. + if (keepAvailable) + candidate.sources = [binding.tabBar, binding.trigger.closest('ion-fab') ?? binding.trigger, binding.footer]; // A first registration on a cached/hidden page has no measurable trigger. // Keep ordinary tabs until the page becomes available; only reuse a previous valid search. continue; @@ -476,8 +508,9 @@ export const createSearchSupport = (doc: Document, id: (element: Element) => str } schedule(); }, - retire(binding: NativeSearchBinding) { + retire(binding: NativeSearchBinding, unavailable = false) { const state = states.get(binding); + if (unavailable && state?.last) state.last = { ...state.last, available: false, active: false, focused: false }; // Keep ownership until keyboardWillHide can release this class. if (state?.keyboardHidden && keyboardVisible) binding.tabBar.classList.add('tab-bar-hidden'); if (state?.focused) { @@ -488,6 +521,8 @@ export const createSearchSupport = (doc: Document, id: (element: Element) => str releaseKeyboardResize(); }, destroy() { + for (const trigger of enteringTriggers.keys()) trigger.classList.remove(prehiddenClass); + enteringTriggers.clear(); states.forEach((state) => { this.retire(state.binding); state.restore(); diff --git a/packages/capacitor-native-ui/src/native/runtime.ts b/packages/capacitor-native-ui/src/native/runtime.ts index 30214bba8..5cb45b5c4 100644 --- a/packages/capacitor-native-ui/src/native/runtime.ts +++ b/packages/capacitor-native-ui/src/native/runtime.ts @@ -88,6 +88,7 @@ export const createRuntime = async ( let verticalGesture: { pending: boolean; scopes: HTMLElement[]; sources: Set } | undefined; let acknowledged: Candidate[] = []; const pages = new Set(); + const tabActivationPages = new Map(); const verticalBarsPages = createVerticalBarsPageState(); const presented = new Set(); const railPopovers = new WeakSet(); @@ -103,7 +104,10 @@ export const createRuntime = async ( let updatingRevision: number | undefined; let pendingActivations: NativeUIActivation[] = []; let lastSequence = 0; + let lastTabSequence = 0; let lastSnapshot = ''; + let renderedSnapshot: NativeUISnapshot | undefined; + let interruptAssets: (() => void) | undefined; let viewport = `${win.innerWidth}:${win.innerHeight}`; let forceRefresh = false; let dirty = false; @@ -269,6 +273,7 @@ export const createRuntime = async ( } }; const blocked = (element: HTMLElement) => + Array.from(tabActivationPages.keys()).some((page) => page.contains(element)) || verticalBarsPages.isDeparted(element, true) || (!isVerticalBarsSource(element) && (Array.from(suspended).some((scopes) => scopes.some((scope) => scope.contains(element))) || @@ -417,13 +422,82 @@ export const createRuntime = async ( frame = 0; dirty = false; pending = true; - if (options.source !== 'extensions') syncToolbarText(doc); handoffInstant = tabSwitchHandoff || handoffAcrossPending || modalPresentHandoff || win.performance.now() < handoffUntil; try { // A retained sync performs no update; keep the modal handoff armed so the // deferred retirement still runs instantly after the overlay releases. if (retentions.size && !manualSuspensions.size) return; modalPresentHandoff = false; + // A tab is a separate page, not a stack transition. Retire its covers before + // rendering destination assets, which may continue changing as the page loads. + // Keep shared surfaces (especially the tab bar and its accessory) native. + if ((tabSwitchHandoff || handoffAcrossPending || win.performance.now() < handoffUntil) && renderedSnapshot && !navigation.current) { + const retained = acknowledged.filter( + (candidate) => + candidate.element.isConnected && + !verticalBarsPages.isDeparted(candidate.element, true) && + !candidate.element.closest('.ion-page-hidden') && + !Array.from(tabActivationPages.keys()).some((page) => page.contains(candidate.element)), + ); + const retiredSearches = new Set( + retained + .filter( + (candidate) => + candidate.control.search?.available && + candidateSources(candidate).some( + (source) => !source.isConnected || verticalBarsPages.isDeparted(source, true) || !!source.closest('.ion-page-hidden'), + ), + ) + .map((candidate) => candidate.control.id), + ); + if (retained.length !== acknowledged.length || retiredSearches.size) { + // The tabs are shared, but their Search belongs to the departing page. + // Retire it before destination assets without returning its FAB to Web. + for (const candidate of retained) { + if (!retiredSearches.has(candidate.control.id)) continue; + const configuration = candidate.control.search!; + getNativeSearchBindings(doc) + .filter((binding) => candidateSources(candidate).includes(binding.footer)) + .forEach((binding) => search.retire(binding, true)); + candidate.control = { ...candidate.control, search: { ...configuration, available: false, active: false, focused: false } }; + candidate.actions.delete(configuration.trigger.id); + candidate.actions.delete(configuration.closeId); + } + const retainedIds = new Set(retained.map((candidate) => candidate.control.id)); + const retainedSources = new Set(retained.flatMap(candidateSources)); + for (const element of sources.keys()) if (!retainedSources.has(element)) restore(element, true); + const retirement: NativeUISnapshot = { + ...renderedSnapshot, + revision: ++revision, + transitionDuration: 0, + toolbarTransition: undefined, + verticalBarNavigation: undefined, + controls: renderedSnapshot.controls + .filter((control) => retainedIds.has(control.id)) + .map((control) => + retiredSearches.has(control.id) + ? { ...control, search: { ...control.search!, available: false, active: false, focused: false } } + : control, + ), + }; + updatingRevision = retirement.revision; + updates++; + const result = await bounded(plugin.update(retirement)); + if (stopped) return; + if (result.revision !== retirement.revision) throw new Error('Native UI revision mismatch'); + renderedSnapshot = retirement; + acceptedRevision = result.revision; + acknowledged = retained; + actions = new Map(retained.flatMap((candidate) => Array.from(candidate.actions))); + lastSnapshot = ''; + const received = pendingActivations; + pendingActivations = []; + received.forEach(activate); + if (tabActivationPages.size) finishWaiters(); + if (dirty) return; + } + } + if (options.source !== 'extensions') syncToolbarText(doc); const size = `${win.innerWidth}:${win.innerHeight}`; // WebKit can resize before Ionic's fixed DOM positions catch up. // Measure on the next frame instead of retiring a valid native search. @@ -437,7 +511,7 @@ export const createRuntime = async ( const signatures = new Map(candidates.map((candidate) => [candidate.element, signature(candidate)])); const rejectionSignatures = new Map(candidates.map((candidate) => [candidate.element, signature(candidate, false)])); const failedAssets = new Set(); - await Promise.all( + const assets = Promise.all( candidates.map(async (candidate) => { try { await candidate.assets?.render(icons); @@ -457,6 +531,10 @@ export const createRuntime = async ( ); }), ); + // A tab change invalidates this page. Do not let an in-flight image decode + // postpone retirement of its native covers. + await Promise.race([assets, new Promise((resolve) => (interruptAssets = resolve))]); + interruptAssets = undefined; candidates = candidates.filter((candidate) => !failedAssets.has(candidate)); if (stopped || dirty || (retentions.size && !manualSuspensions.size)) return; const retained = new Set([...candidates.flatMap(candidateSources), ...(verticalGesture?.sources ?? [])]); @@ -555,7 +633,10 @@ export const createRuntime = async ( controls: candidates.map((candidate) => candidate.control), }; const serialized = JSON.stringify(data); - if (serialized === lastSnapshot && !forceRefresh) return; + if (serialized === lastSnapshot && !forceRefresh) { + search.settle((element) => Array.from(pages).some((page) => page.contains(element))); + return; + } const snapshot: NativeUISnapshot = { ...data, revision: ++revision, @@ -615,6 +696,7 @@ export const createRuntime = async ( .flatMap((candidate) => Array.from(candidate.actions.values())), ); const invalidated = candidates.length !== accepted.length; + renderedSnapshot = snapshot; acceptedRevision = result.revision; lastSnapshot = invalidated ? '' : serialized; actions = new Map( @@ -657,6 +739,7 @@ export const createRuntime = async ( if (newlyProjected) element.dispatchEvent(new CustomEvent('nativeUIChange')); } } + if (!dirty && !invalidated) search.settle((element) => Array.from(pages).some((page) => page.contains(element))); // Native has acknowledged this rendered snapshot even when a concurrent DOM // mutation invalidates its signature. An existing cover is still visible; // keep that snapshot available if navigation starts before the next update. @@ -796,6 +879,7 @@ export const createRuntime = async ( const pageWill: EventListener = (event) => { const page = event.target as HTMLElement; verticalBarsPages.lifecycle(event); + tabActivationPages.delete(page); getNativeSearchBindings(doc) .filter((binding) => page.contains(binding.footer)) .forEach((binding) => search.retire(binding)); @@ -810,11 +894,13 @@ export const createRuntime = async ( maskNavigationPage(page); } pages.add(page); + if (event.type === LIFECYCLE_WILL_ENTER && nativeVerticalBars()) search.prepare(page); schedule(); }; const pageDid: EventListener = (event) => { const page = event.target as HTMLElement; verticalBarsPages.lifecycle(event); + tabActivationPages.delete(page); pages.delete(page); if (navigation.current && !navigation.current.leased && pages.size === 0) finishNavigation(); schedule(); @@ -834,6 +920,9 @@ export const createRuntime = async ( }; const armTabSwitchHandoff = () => { tabSwitchHandoff = true; + navigation.clear(); + interruptAssets?.(); + crossfade.finish(); // Retiring outgoing controls often lands in a later async sync than WillLeave/DidChange. handoffUntil = Math.max(handoffUntil, win.performance.now() + 400); // The in-flight sync already captured handoffInstant=false; hold instant for the next one. @@ -960,6 +1049,7 @@ export const createRuntime = async ( on(win, 'scroll'); on(win.matchMedia('(prefers-color-scheme: dark)'), 'change'); on(win, 'nativeUIRefresh', (event) => { + if (nativeVerticalBars()) search.prepare(); if ((event as Event & { retireSearch?: boolean }).retireSearch) getNativeSearchBindings(doc).forEach((binding) => search.retire(binding)); refreshLayout(); @@ -1089,6 +1179,40 @@ export const createRuntime = async ( candidate?.control.search && [candidate.control.search.trigger.id, candidate.control.search.closeId].includes(event.id); const extensionAction = candidate?.control.kind === 'extension' && candidate.actions.get(event.id) === element; if (!extensionAction && !searchAction && (!item || item.disabled || item.visible === false)) return false; + // A native tab tap must release the old page's covers before Ionic exposes + // the destination. Waiting for WillLeave is too late for an unanimated swap. + if (element.matches('ion-tab-button')) lastTabSequence = event.sequence; + if (element.matches('ion-tab-button') && !element.classList.contains('tab-selected')) { + const tabs = element.closest('ion-tabs'); + const leaving = new Set( + acknowledged + .map((source) => source.element.closest('.ion-page')) + .filter((page): page is HTMLElement => !!page && !!tabs?.contains(page)), + ); + leaving.forEach((page) => tabActivationPages.set(page, event.sequence)); + armTabSwitchHandoff(); + void flush().then(() => { + if (stopped) return; + if (event.sequence === lastTabSequence && element.isConnected && !(element as HTMLIonTabButtonElement).disabled) + activateProjectedElement(element); + // WillLeave/WillEnter release these scopes once Ionic commits the route. + // If a router guard rejects it, restore native ownership after the handoff window. + win.setTimeout( + () => { + leaving.forEach((page) => { + if (tabActivationPages.get(page) === event.sequence) tabActivationPages.delete(page); + }); + // Rejected selection emits no DidChange/pageDid to end the handoff. + if (!tabActivationPages.size && !pages.size) endTabSwitchHandoff(); + if (!stopped) schedule(); + }, + Math.max(0, handoffUntil - win.performance.now()), + ); + lastSnapshot = ''; + schedule(); + }); + return true; + } // The original Ionic host owns form submission, routerLink and selection events. activateProjectedElement(element); lastSnapshot = ''; // Reconcile even if Ionic rejects the proposed native selection. diff --git a/packages/capacitor-native-ui/src/native/shared/crossfade.ts b/packages/capacitor-native-ui/src/native/shared/crossfade.ts index 4af0b9898..d8a2af47d 100644 --- a/packages/capacitor-native-ui/src/native/shared/crossfade.ts +++ b/packages/capacitor-native-ui/src/native/shared/crossfade.ts @@ -21,15 +21,16 @@ export const createCrossfade = (win: Window) => { duration: (instant = false) => (reduced.matches || instant ? 0 : handoffDuration), play(element: HTMLElement, toNative: boolean, instant = false) { const previous = active.get(element); - const current = Number(win.getComputedStyle(element).opacity); - previous?.cancel(); - active.delete(element); - const opacity = Number(win.getComputedStyle(element).opacity); - // Tab switches skip the opacity handoff so a retiring native snapshot cannot linger. + // Instant handoffs must not force layout before retiring an outgoing tab. if (reduced.matches || instant || !element.isConnected) { + if (previous) finish(element, previous); element.removeAttribute(fadeMarker); return; } + const current = Number(win.getComputedStyle(element).opacity); + previous?.cancel(); + active.delete(element); + const opacity = Number(win.getComputedStyle(element).opacity); element.setAttribute(fadeMarker, ''); const animation = element.animate([{ opacity: previous ? current : toNative ? opacity : 0 }, { opacity: toNative ? 0 : opacity }], { duration: handoffDuration, @@ -55,6 +56,7 @@ export const createCrossfade = (win: Window) => { retired.add(element); api.play(element, true); }, + finish: cancelAll, async settled() { await Promise.all(Array.from(active.values(), (animation) => animation.finished.catch(() => {}))); },