diff --git a/demo/e2e/native-ui.spec.ts b/demo/e2e/native-ui.spec.ts index 2acc4ff46..ac64c4053 100644 --- a/demo/e2e/native-ui.spec.ts +++ b/demo/e2e/native-ui.spec.ts @@ -4846,3 +4846,40 @@ for (const vertical of [false, true]) { await expect.poll(tint).toBe('rgba(120, 40, 80, 0.8)'); }); } + +test('Duo tab handoff recovers from one update failure without replaying the tab click', async ({ page }) => { + await mockNative(page); + await page.goto('/main/index/native-ui'); + await page.locator('ion-app').evaluate((app) => app.classList.add('ios-theme-vertical-bars')); + const segment = page.locator('app-native-ui ion-segment').first(); + await expect(segment).toHaveAttribute('data-native-ui-projected', ''); + await expect + .poll(() => + page.evaluate(() => + Capacitor.registerPlugin('NativeUI') + .updates.at(-1) + ?.controls.some((control) => control.kind === 'tab-bar' && control.placement === 'vertical-bars'), + ), + ) + .toBe(true); + await page.evaluate(() => { + const plugin = Capacitor.registerPlugin('NativeUI'); + const update = plugin.update.bind(plugin); + plugin.update = async () => { + plugin.update = update; + throw new Error('Temporary update failure'); + }; + const tab = document.querySelector('ion-tab-button[tab="album"]')!; + tab.setAttribute('data-activations', '0'); + tab.addEventListener('click', () => tab.setAttribute('data-activations', String(Number(tab.getAttribute('data-activations')) + 1))); + }); + await activate(page, 'Library', true); + await expect(page).toHaveURL(/\/main\/album/); + await expect(page.locator('ion-tab-button[tab="album"]')).toHaveAttribute('data-activations', '1'); + await expect + .poll(() => page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).nativeUI!.getStatus().state)) + .toBe('native'); + expect(await page.evaluate(() => (document.querySelector('ion-app') as TestAppElement).nativeUI!.getStatus().reason)).toBeUndefined(); + await activate(page, 'Index'); + await expect(segment).toHaveAttribute('data-native-ui-projected', ''); +}); diff --git a/demo/src/native-ui-android.spec.ts b/demo/src/native-ui-android.spec.ts index 3136b7e65..8fa3a860d 100644 --- a/demo/src/native-ui-android.spec.ts +++ b/demo/src/native-ui-android.spec.ts @@ -215,7 +215,9 @@ test.each(['bridge', 'pagehide', 'bridge then pagehide'])('shell restarts after const previous = shell; await vi.waitFor(() => expect(previous.getStatus().projected).toBe(2)); if (cause !== 'pagehide') { - bridge.update.mockRejectedValueOnce(new Error('Temporary bridge failure')); + bridge.update + .mockRejectedValueOnce(new Error('Persistent bridge failure')) + .mockRejectedValueOnce(new Error('Persistent bridge failure')); count++; previous.markForCheck(); } else window.dispatchEvent(new Event('pagehide')); diff --git a/demo/src/native-ui-horizontal-navigation.spec.ts b/demo/src/native-ui-horizontal-navigation.spec.ts index 7877d8244..5f4883d06 100644 --- a/demo/src/native-ui-horizontal-navigation.spec.ts +++ b/demo/src/native-ui-horizontal-navigation.spec.ts @@ -674,3 +674,127 @@ test('an acknowledged navigation handoff does not wait for unrelated refreshes t refresh = false; } }); + +test.each([false, true])( + 'one failed native update resynchronizes current DOM without restarting or replaying input (vertical=%s)', + async (vertical) => { + const f = await fixture(); + const source = f.leaving.querySelector('button')!; + if (vertical) { + document.querySelector('ion-app')!.classList.add('ios-theme-vertical-bars'); + source.setAttribute('data-native-ui-placement', 'rail'); + await vi.waitFor(() => expect(f.snapshots.at(-1)?.controls[0]?.placement).toBe('vertical-bars')); + } + const clicked = vi.fn(); + source.addEventListener('click', clicked); + const listener = f.listeners.get('activate'); + let failedRevision = 0; + let retryFrame: FrameRequestCallback | undefined; + let frameSpy: ReturnType | undefined; + f.setAck(async (snapshot) => { + if (!failedRevision) { + failedRevision = snapshot.revision; + source.textContent = 'Latest'; + frameSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + retryFrame = callback; + return 1; + }); + throw new Error('Temporary connection failure'); + } + return { revision: snapshot.revision }; + }); + source.textContent = 'Changed'; + try { + await vi.waitFor(() => expect(retryFrame).toBeDefined()); + listener!({ id: f.snapshots.at(-1)!.controls[0].items[0].id, revision: failedRevision, sequence: 1 }); + expect(clicked).not.toHaveBeenCalled(); + } finally { + frameSpy?.mockRestore(); + retryFrame?.(performance.now()); + } + await vi.waitFor(() => expect(f.snapshots.at(-1)?.controls[0]?.items[0]?.label).toBe('Latest')); + await vi.waitFor(() => expect(source.hasAttribute('data-native-ui-projected')).toBe(true)); + expect(handle!.getStatus().state).toBe('native'); + expect(handle!.getStatus().reason).toBeUndefined(); + expect(f.listeners.get('activate')).toBe(listener); + expect(clicked).not.toHaveBeenCalled(); + const current = f.snapshots.at(-1)!; + expect(current.revision).toBeGreaterThan(failedRevision); + const updates = f.snapshots.length; + handle!.markForCheck(); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); + expect(f.snapshots).toHaveLength(updates); + listener!({ id: current.controls[0].items[0].id, revision: failedRevision, sequence: 1 }); + expect(clicked).not.toHaveBeenCalled(); + listener!({ id: current.controls[0].items[0].id, revision: current.revision, sequence: 2 }); + listener!({ id: current.controls[0].items[0].id, revision: current.revision, sequence: 2 }); + expect(clicked).toHaveBeenCalledOnce(); + }, +); + +test.each(['rejection', 'revision mismatch'])('native update %s respects the retry boundary', async (failure) => { + const f = await fixture(); + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}); + let attempts = 0; + f.setAck(async (snapshot) => { + attempts++; + if (failure === 'revision mismatch') return { revision: snapshot.revision + 1 }; + throw new Error('Persistent connection failure'); + }); + f.leaving.querySelector('button')!.textContent = 'Changed'; + await vi.waitFor(() => expect(handle!.getStatus().state).toBe('stopped')); + expect(attempts).toBe(failure === 'revision mismatch' ? 1 : 2); + expect(handle!.getStatus().reason).toBe( + failure === 'revision mismatch' ? 'Native UI revision mismatch' : 'Persistent connection failure', + ); + expect(f.leaving.querySelector('[data-native-ui-projected]')).toBeNull(); + expect(warning).toHaveBeenCalledOnce(); +}); + +test.each([false, true])('an update acknowledged during retention reconnects unchanged DOM (vertical=%s)', async (vertical) => { + const f = await fixture(); + const source = f.leaving.querySelector('button')!; + if (vertical) { + document.querySelector('ion-app')!.classList.add('ios-theme-vertical-bars'); + source.setAttribute('data-native-ui-placement', 'rail'); + await vi.waitFor(() => expect(f.snapshots.at(-1)?.controls[0]?.placement).toBe('vertical-bars')); + } + const initial = f.snapshots.at(-1)!; + let acknowledge: (() => void) | undefined; + f.setAck( + (snapshot) => + new Promise((resolve) => { + acknowledge = () => resolve({ revision: snapshot.revision }); + }), + ); + source.textContent = 'Changed'; + await vi.waitFor(() => expect(acknowledge).toBeDefined()); + const release = (handle as NativeUIRuntimeHandle & { retain(): () => void }).retain(); + source.textContent = 'Edit'; + f.setAck(undefined); + acknowledge!(); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + release(); + await vi.waitFor(() => expect(f.snapshots.at(-1)?.controls[0]?.items[0]?.label).toBe('Edit')); + expect(f.snapshots.at(-1)!.revision).toBeGreaterThan(initial.revision); + const clicked = vi.fn(); + source.addEventListener('click', clicked); + const current = f.snapshots.at(-1)!; + f.listeners.get('activate')!({ id: current.controls[0].items[0].id, revision: current.revision, sequence: 1 }); + expect(clicked).toHaveBeenCalledOnce(); +}); + +test('a successful synchronization restores the single retry allowance', async () => { + const f = await fixture(); + for (const label of ['First', 'Second']) { + let attempts = 0; + f.setAck(async (snapshot) => { + if (++attempts === 1) throw new Error('Temporary failure'); + return { revision: snapshot.revision }; + }); + f.leaving.querySelector('button')!.textContent = label; + await vi.waitFor(() => expect(attempts).toBe(2)); + expect(handle!.getStatus().state).toBe('native'); + expect(f.snapshots.at(-1)?.controls[0]?.items[0]?.label).toBe(label); + } +}); diff --git a/packages/capacitor-native-ui/docs/native-ui.md b/packages/capacitor-native-ui/docs/native-ui.md index 30113bc1b..872bef1c5 100644 --- a/packages/capacitor-native-ui/docs/native-ui.md +++ b/packages/capacitor-native-ui/docs/native-ui.md @@ -327,7 +327,7 @@ Native projection resumes only after every overlay requiring Web rendering close During retirement, the source is restored and allowed to paint before its native cover is removed. During acquisition, the source is hidden only after a successful, current native response. Delayed responses are revalidated per control: existing eligible controls retain their native cover while content updates catch up. Only removed or ineligible sources return to Web; newly acquired sources require an exact acknowledgement. Ordinary page mutations never call the global clear operation. UIKit tab instances and items are retained, and equal frames/selections are not reapplied. Duplicate/stale activations are discarded. WebKit and UIKit still render separately: the implementation avoids an intentional blank frame, but does not provide an OS-level atomic compositing guarantee. Validate custom transitions and overlays on the app's supported simulators before rollout; unknown overlay systems are outside the automatic integration contract. -If a bridge update fails or times out, native projection stops and Web rendering is restored. It does not automatically reconnect. `getStatus().reason` reports the failure; while the Web fallback remains active, the public handle reports `state: 'web'`, not `stopped`. To retry deliberately, call `destroy()` on that handle and then `enableNativeUI()` again. +After one bridge update failure, the runtime keeps the connection and retries state synchronization once using the latest DOM. It does not replay clicks or application actions. Normal successful updates retain snapshot deduplication. The retry can delay fallback by one additional update attempt; each bridge update has a five-second timeout, while an enclosing navigation flush retains its existing five-second deadline. If the retry fails, the update revision mismatches, or that deadline expires, native projection stops and Web rendering is restored. Once stopped, it does not automatically reconnect. `getStatus().reason` reports the failure; while the Web fallback remains active, the public handle reports `state: 'web'`, not `stopped`. To retry deliberately, call `destroy()` on that handle and then `enableNativeUI()` again. Failures or timeouts in required startup bridge calls return a Web fallback handle. Optional WebView metrics may fail without disabling native controls. `destroy()` waits for teardown; repeated calls share its completion. A new activation requested while teardown is running waits for it, including when the application calls `destroy()` without awaiting it. Await these promises when the next application operation depends on cleanup, and handle any rejection. diff --git a/packages/capacitor-native-ui/src/native/runtime.ts b/packages/capacitor-native-ui/src/native/runtime.ts index 5cb45b5c4..e0fbf3843 100644 --- a/packages/capacitor-native-ui/src/native/runtime.ts +++ b/packages/capacitor-native-ui/src/native/runtime.ts @@ -112,6 +112,7 @@ export const createRuntime = async ( let forceRefresh = false; let dirty = false; let pending = false; + let retryingUpdate = false; let stopped = false; let destruction: Promise | undefined; const retentions = new Set<{ accepts?: (id: string) => boolean; activation?: NativeUIActivation }>(); @@ -418,6 +419,28 @@ export const createRuntime = async ( // The Web fallback re-evaluates eligibility once native projection is stopped. win.dispatchEvent(new Event('nativeUIRefresh')); }; + const updateNative = async (snapshot: NativeUISnapshot) => { + // Native can apply this update even if retention or viewport changes prevent + // accepting its response. Only a fully accepted snapshot is safe to deduplicate. + lastSnapshot = ''; + let result; + try { + result = await bounded(plugin.update(snapshot)); + } catch (error) { + if (stopped) return; + if (retryingUpdate) throw error; + // Keep the existing connection. A fresh read on the next frame retries + // only state synchronization, never the action that triggered it. + retryingUpdate = true; + forceRefresh = true; + dirty = true; + diagnose('sync-retry: refreshing current state after an update failure.'); + return; + } + if (result.revision !== snapshot.revision) throw new Error('Native UI revision mismatch'); + retryingUpdate = false; + return result; + }; const sync = async () => { frame = 0; dirty = false; @@ -482,9 +505,8 @@ export const createRuntime = async ( }; 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'); + const result = await updateNative(retirement); + if (stopped || !result) return; renderedSnapshot = retirement; acceptedRevision = result.revision; acknowledged = retained; @@ -646,9 +668,8 @@ export const createRuntime = async ( forceRefresh = false; updates++; updatingRevision = snapshot.revision; - const result = await bounded(plugin.update(snapshot)); - if (stopped || (retentions.size && !manualSuspensions.size)) return; - if (result.revision !== snapshot.revision) throw new Error('Native UI revision mismatch'); + const result = await updateNative(snapshot); + if (stopped || !result || (retentions.size && !manualSuspensions.size)) return; if (transition && navigation.current === transition && result.toolbarTransition === transition.id) { transition.native = true; navigation.mask( @@ -1166,6 +1187,8 @@ export const createRuntime = async ( pendingActivations.push(event); return; } + // A failed update may have reached native without acknowledging its actions. + if (event.revision > acceptedRevision) return false; lastSequence = event.sequence; if (!element || (verticalGesture && owner && isVerticalBarsSource(owner))) return false; if (!owner || !inVerticalBarsSurface(owner)) return false;