diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index dbdb45c89..51fe75f9c 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -65,6 +65,11 @@ const filePayloadsPath = new URL( const filePayloadsSource = fs.readFileSync(filePayloadsPath, 'utf8'); const hapticsPath = new URL('../src/host-bridge/haptics.ts', import.meta.url); const hapticsSource = fs.readFileSync(hapticsPath, 'utf8'); +const hapticsTestPath = new URL( + '../src/host-bridge/haptics.test.ts', + import.meta.url, +); +const hapticsTestSource = fs.readFileSync(hapticsTestPath, 'utf8'); const hostBridgeNavigationPath = new URL( '../src/host-bridge/navigation.ts', import.meta.url, @@ -2209,6 +2214,7 @@ if ( !hapticsSource.includes('Haptics.ImpactFeedbackStyle.Medium') || !hapticsSource.includes('Haptics.ImpactFeedbackStyle.Light') || !hapticsSource.includes('await Haptics.impactAsync(toExpoImpactStyle(style))') || + !hapticsSource.includes("message: 'haptics impact unavailable'") || !hapticsSource.includes('ok(request, true)') ) { throw new Error( @@ -2216,6 +2222,15 @@ if ( ); } +for (const snippet of [ + 'reports unavailable native feedback with a stable HostBridge error', + "message: 'haptics impact unavailable'", +]) { + if (!hapticsTestSource.includes(snippet)) { + throw new Error(`mobile shell haptics tests missing ${snippet}`); + } +} + if ( dispatchSource.includes("from 'expo-haptics'") || dispatchSource.includes('Haptics.impactAsync') || diff --git a/apps/mobile-shell/src/host-bridge/haptics.test.ts b/apps/mobile-shell/src/host-bridge/haptics.test.ts index d40ab534b..25b3206d8 100644 --- a/apps/mobile-shell/src/host-bridge/haptics.test.ts +++ b/apps/mobile-shell/src/host-bridge/haptics.test.ts @@ -66,6 +66,17 @@ describe('mobile haptics helpers', () => { expect(Haptics.impactAsync).not.toHaveBeenCalled(); }); + test('reports unavailable native feedback with a stable HostBridge error', async () => { + vi.mocked(Haptics.impactAsync).mockRejectedValueOnce( + new Error('native haptics failed'), + ); + + await expect(runMobileHapticsImpact('light')).rejects.toMatchObject({ + code: 'host_error', + message: 'haptics impact unavailable', + }); + }); + test('wraps HostBridge success response after real impact dispatch', async () => { const response = await runMobileHostBridgeHapticsImpact( request({ diff --git a/apps/mobile-shell/src/host-bridge/haptics.ts b/apps/mobile-shell/src/host-bridge/haptics.ts index b701a38ad..f941a9701 100644 --- a/apps/mobile-shell/src/host-bridge/haptics.ts +++ b/apps/mobile-shell/src/host-bridge/haptics.ts @@ -2,6 +2,7 @@ import * as Haptics from 'expo-haptics'; import { type HapticsImpactPayload, + type HostBridgeError, type HostBridgeRequest, normalizeHostBridgeHapticsImpactStyle, type HostBridgeHapticsImpactStyle, @@ -22,7 +23,14 @@ export async function runMobileHapticsImpact(rawStyle: unknown) { return false; } - await Haptics.impactAsync(toExpoImpactStyle(style)); + try { + await Haptics.impactAsync(toExpoImpactStyle(style)); + } catch { + throw { + code: 'host_error', + message: 'haptics impact unavailable', + } satisfies HostBridgeError; + } return true; }