From 2c185ceb89167cea568a09c6afa612721747f217 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sat, 24 Oct 2026 03:32:32 +0900 Subject: [PATCH 1/4] fix: retry native state synchronization after a transient update failure --- demo/e2e/native-ui.spec.ts | 37 +++++++++++++ demo/src/native-ui-android.spec.ts | 4 +- .../native-ui-horizontal-navigation.spec.ts | 54 +++++++++++++++++++ .../capacitor-native-ui/src/native/runtime.ts | 31 ++++++++--- 4 files changed, 119 insertions(+), 7 deletions(-) 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..da4f088bb 100644 --- a/demo/src/native-ui-horizontal-navigation.spec.ts +++ b/demo/src/native-ui-horizontal-navigation.spec.ts @@ -674,3 +674,57 @@ 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; + f.setAck(async (snapshot) => { + if (!failedRevision) { + failedRevision = snapshot.revision; + source.textContent = 'Latest'; + throw new Error('Temporary connection failure'); + } + return { revision: snapshot.revision }; + }); + source.textContent = 'Changed'; + 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); + 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 }); + expect(clicked).toHaveBeenCalledOnce(); + }, +); + +test('a persistent native update failure stops after one resynchronization attempt', async () => { + const f = await fixture(); + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}); + let attempts = 0; + f.setAck(async () => { + attempts++; + throw new Error('Persistent connection failure'); + }); + f.leaving.querySelector('button')!.textContent = 'Changed'; + await vi.waitFor(() => expect(handle!.getStatus().state).toBe('stopped')); + expect(attempts).toBe(2); + expect(handle!.getStatus().reason).toBe('Persistent connection failure'); + expect(f.leaving.querySelector('[data-native-ui-projected]')).toBeNull(); + expect(warning).toHaveBeenCalledOnce(); +}); diff --git a/packages/capacitor-native-ui/src/native/runtime.ts b/packages/capacitor-native-ui/src/native/runtime.ts index 5cb45b5c4..86acbfeab 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,26 @@ 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) => { + 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; + lastSnapshot = ''; + 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 +503,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 +666,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( From 0415438f44bedc9116f4cf728a8b10697e4c8797 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sat, 24 Oct 2026 07:14:41 +0900 Subject: [PATCH 2/4] fix: reject unacknowledged input between sync retries --- demo/src/native-ui-horizontal-navigation.spec.ts | 14 ++++++++++++++ packages/capacitor-native-ui/src/native/runtime.ts | 2 ++ 2 files changed, 16 insertions(+) diff --git a/demo/src/native-ui-horizontal-navigation.spec.ts b/demo/src/native-ui-horizontal-navigation.spec.ts index da4f088bb..ead7e75cf 100644 --- a/demo/src/native-ui-horizontal-navigation.spec.ts +++ b/demo/src/native-ui-horizontal-navigation.spec.ts @@ -689,15 +689,29 @@ test.each([false, true])( 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'); diff --git a/packages/capacitor-native-ui/src/native/runtime.ts b/packages/capacitor-native-ui/src/native/runtime.ts index 86acbfeab..0728fd22f 100644 --- a/packages/capacitor-native-ui/src/native/runtime.ts +++ b/packages/capacitor-native-ui/src/native/runtime.ts @@ -1185,6 +1185,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; From c4b80ab8f1e91633098cb8438dea6c9806e4504b Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sat, 24 Oct 2026 07:27:04 +0900 Subject: [PATCH 3/4] fix: reconcile native updates after interrupted acknowledgements --- .../native-ui-horizontal-navigation.spec.ts | 33 +++++++++++++++++++ .../capacitor-native-ui/src/native/runtime.ts | 4 ++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/demo/src/native-ui-horizontal-navigation.spec.ts b/demo/src/native-ui-horizontal-navigation.spec.ts index ead7e75cf..fe4674db4 100644 --- a/demo/src/native-ui-horizontal-navigation.spec.ts +++ b/demo/src/native-ui-horizontal-navigation.spec.ts @@ -742,3 +742,36 @@ test('a persistent native update failure stops after one resynchronization attem 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(); +}); diff --git a/packages/capacitor-native-ui/src/native/runtime.ts b/packages/capacitor-native-ui/src/native/runtime.ts index 0728fd22f..e0fbf3843 100644 --- a/packages/capacitor-native-ui/src/native/runtime.ts +++ b/packages/capacitor-native-ui/src/native/runtime.ts @@ -420,6 +420,9 @@ export const createRuntime = async ( 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)); @@ -429,7 +432,6 @@ export const createRuntime = async ( // Keep the existing connection. A fresh read on the next frame retries // only state synchronization, never the action that triggered it. retryingUpdate = true; - lastSnapshot = ''; forceRefresh = true; dirty = true; diagnose('sync-retry: refreshing current state after an update failure.'); From 216cc82262870e02465c8a78769ec25e482d14e8 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sat, 24 Oct 2026 07:39:13 +0900 Subject: [PATCH 4/4] test: guard native sync retry tradeoffs and document recovery --- .../native-ui-horizontal-navigation.spec.ts | 31 ++++++++++++++++--- .../capacitor-native-ui/docs/native-ui.md | 2 +- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/demo/src/native-ui-horizontal-navigation.spec.ts b/demo/src/native-ui-horizontal-navigation.spec.ts index fe4674db4..5f4883d06 100644 --- a/demo/src/native-ui-horizontal-navigation.spec.ts +++ b/demo/src/native-ui-horizontal-navigation.spec.ts @@ -720,25 +720,33 @@ test.each([false, true])( 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('a persistent native update failure stops after one resynchronization attempt', async () => { +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 () => { + 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(2); - expect(handle!.getStatus().reason).toBe('Persistent connection failure'); + 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(); }); @@ -775,3 +783,18 @@ test.each([false, true])('an update acknowledged during retention reconnects unc 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.