Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions demo/e2e/native-ui.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NativeUIMock>('NativeUI')
.updates.at(-1)
?.controls.some((control) => control.kind === 'tab-bar' && control.placement === 'vertical-bars'),
),
)
.toBe(true);
await page.evaluate(() => {
const plugin = Capacitor.registerPlugin<NativeUIMock>('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', '');
});
4 changes: 3 additions & 1 deletion demo/src/native-ui-android.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down
124 changes: 124 additions & 0 deletions demo/src/native-ui-horizontal-navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.spyOn> | 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<void>((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<void>((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);
}
});
2 changes: 1 addition & 1 deletion packages/capacitor-native-ui/docs/native-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
35 changes: 29 additions & 6 deletions packages/capacitor-native-ui/src/native/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> | undefined;
const retentions = new Set<{ accepts?: (id: string) => boolean; activation?: NativeUIActivation }>();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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;
Expand Down
Loading