Skip to content
14 changes: 13 additions & 1 deletion app/src/components/settings/panels/TeamInvitesPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import debug from 'debug';
import { useEffect, useState } from 'react';
import { useLocation, useParams } from 'react-router-dom';

import { useT } from '../../../lib/i18n/I18nContext';
import { useCoreState } from '../../../providers/CoreStateProvider';
import { teamApi } from '../../../services/api/teamApi';
import { sanitizeError } from '../../../utils/sanitize';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';

const log = debug('core-rpc:error');

const TeamInvitesPanel = () => {
const { t } = useT();
const { teamId } = useParams<{ teamId: string }>();
Expand Down Expand Up @@ -34,7 +38,15 @@ const TeamInvitesPanel = () => {
useEffect(() => {
if (!currentTeamId) return;
setIsLoadingInvites(true);
void refreshTeamInvites(currentTeamId).finally(() => setIsLoadingInvites(false));
// `.finally()` alone left this as `void promise(...)`, so any rejection
// (cold core boot, backend 504, local AbortController timeout) became an
// unhandled rejection → OPENHUMAN-REACT-12. Swallow into a logged
// breadcrumb; the user can retry by navigating away and back.
refreshTeamInvites(currentTeamId)
.catch(err => {
log('refreshTeamInvites failed in TeamInvitesPanel: %O', sanitizeError(err));
})
.finally(() => setIsLoadingInvites(false));
}, [currentTeamId, refreshTeamInvites]);

const handleGenerate = async () => {
Expand Down
14 changes: 13 additions & 1 deletion app/src/components/settings/panels/TeamMembersPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import debug from 'debug';
import { useEffect, useState } from 'react';
import { useLocation, useParams } from 'react-router-dom';

import { useT } from '../../../lib/i18n/I18nContext';
import { useCoreState } from '../../../providers/CoreStateProvider';
import { teamApi } from '../../../services/api/teamApi';
import type { TeamMember, TeamRole } from '../../../types/team';
import { sanitizeError } from '../../../utils/sanitize';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';

const log = debug('core-rpc:error');

const ROLES: TeamRole[] = ['ADMIN', 'BILLING_MANAGER', 'MEMBER'];

const TeamMembersPanel = () => {
Expand Down Expand Up @@ -41,7 +45,15 @@ const TeamMembersPanel = () => {
useEffect(() => {
if (!currentTeamId) return;
setIsLoadingMembers(true);
void refreshTeamMembers(currentTeamId).finally(() => setIsLoadingMembers(false));
// `.finally()` alone left this as `void promise(...)`, so any rejection
// (cold core boot, backend 504, local AbortController timeout) became an
// unhandled rejection → OPENHUMAN-REACT-10. Swallow into a logged
// breadcrumb; the user can retry by navigating away and back.
refreshTeamMembers(currentTeamId)
.catch(err => {
log('refreshTeamMembers failed in TeamMembersPanel: %O', sanitizeError(err));
})
.finally(() => setIsLoadingMembers(false));
}, [currentTeamId, refreshTeamMembers]);

const handleChangeRole = (member: TeamMember, newRole: TeamRole) => {
Expand Down
20 changes: 19 additions & 1 deletion app/src/components/settings/panels/TeamPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import debug from 'debug';
import { useCallback, useEffect, useState } from 'react';

import { useT } from '../../../lib/i18n/I18nContext';
import { useCoreState } from '../../../providers/CoreStateProvider';
import { teamApi } from '../../../services/api/teamApi';
import { CoreRpcError } from '../../../services/coreRpcClient';
import type { TeamWithRole } from '../../../types/team';
import { sanitizeError } from '../../../utils/sanitize';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';

const log = debug('core-rpc:error');

const TeamPanel = () => {
const { t } = useT();
const { navigateBack, navigateToTeamManagement, breadcrumbs } = useSettingsNavigation();
Expand All @@ -30,13 +35,26 @@ const TeamPanel = () => {
setIsLoading(true);
try {
await refreshTeams();
} catch (err) {
// Bootstrap-time `team_list_teams` failures (cold core boot, backend
// 504, local AbortController hit `CORE_RPC_TIMEOUT_MS`) used to leak
// as unhandled promise rejections via the `void` in the useEffect
// below, polluting Sentry as OPENHUMAN-REACT-15/11. The next visible
// user action retries, so swallow silently for transient kinds.
const kind = err instanceof CoreRpcError ? err.kind : 'unknown';
log('refreshTeams failed in TeamPanel (kind=%s): %O', kind, sanitizeError(err));
} finally {
setIsLoading(false);
}
}, [refreshTeams]);

useEffect(() => {
void refreshTeamsWithLoading();
// `refreshTeamsWithLoading` already absorbs rejections internally, but
// keep the `.catch()` as a belt-and-suspenders guard so a future refactor
// that re-throws cannot regress the unhandled-rejection family.
refreshTeamsWithLoading().catch(err => {
log('refreshTeamsWithLoading rethrew unexpectedly: %O', sanitizeError(err));
});
}, [refreshTeamsWithLoading]);

const handleCreateTeam = async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* TeamInvitesPanel — unhandled-rejection guard test.
*
* Regression coverage for OPENHUMAN-REACT-12: bootstrap-time
* `team_list_invites` failures leaked through the
* `void refreshTeamInvites(...).finally(...)` pattern. The new explicit
* `.catch()` chained before `.finally()` must absorb the rejection
* silently.
*/
import { render, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';

import { useCoreState } from '../../../../providers/CoreStateProvider';
import { CoreRpcError } from '../../../../services/coreRpcClient';
import TeamInvitesPanel from '../TeamInvitesPanel';

vi.mock('../../../../providers/CoreStateProvider', () => ({ useCoreState: vi.fn() }));
vi.mock('../../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
vi.mock('../../hooks/useSettingsNavigation', () => ({
useSettingsNavigation: () => ({ navigateBack: vi.fn(), breadcrumbs: [] }),
}));
vi.mock('../../components/SettingsHeader', () => ({ default: () => null }));
vi.mock('react-router-dom', () => ({
useParams: () => ({ teamId: 'team-u1' }),
useLocation: () => ({ pathname: '/team/manage/team-u1' }),
}));

describe('TeamInvitesPanel — unhandled-rejection guard (#REACT-12)', () => {
let urEvents: PromiseRejectionEvent[];
const urHandler = (e: PromiseRejectionEvent) => {
urEvents.push(e);
};

beforeEach(() => {
urEvents = [];
window.addEventListener('unhandledrejection', urHandler);
});

afterEach(() => {
window.removeEventListener('unhandledrejection', urHandler);
vi.clearAllMocks();
});

test('swallows refreshTeamInvites CoreRpcError(timeout) without unhandledrejection', async () => {
const refreshTeamInvites = vi
.fn()
.mockRejectedValue(
new CoreRpcError('Core RPC openhuman.team_list_invites timed out after 30000ms', 'timeout')
);
vi.mocked(useCoreState).mockReturnValue({
snapshot: { currentUser: { _id: 'u1', activeTeamId: 'team-u1' } },
teams: [{ team: { _id: 'team-u1', name: 'T' }, role: 'ADMIN' }],
teamInvitesById: {},
refreshTeamInvites,
} as never);

render(<TeamInvitesPanel />);
await waitFor(() => expect(refreshTeamInvites).toHaveBeenCalledWith('team-u1'));
await new Promise(r => setTimeout(r, 20));

expect(urEvents).toHaveLength(0);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* TeamMembersPanel — unhandled-rejection guard test.
*
* Regression coverage for OPENHUMAN-REACT-10: bootstrap-time
* `team_list_members` failures leaked through the
* `void refreshTeamMembers(...).finally(...)` pattern. The new explicit
* `.catch()` chained before `.finally()` must absorb the rejection
* silently.
*/
import { render, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';

import { useCoreState } from '../../../../providers/CoreStateProvider';
import { CoreRpcError } from '../../../../services/coreRpcClient';
import TeamMembersPanel from '../TeamMembersPanel';

vi.mock('../../../../providers/CoreStateProvider', () => ({ useCoreState: vi.fn() }));
vi.mock('../../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
vi.mock('../../hooks/useSettingsNavigation', () => ({
useSettingsNavigation: () => ({ navigateBack: vi.fn(), breadcrumbs: [] }),
}));
vi.mock('../../components/SettingsHeader', () => ({ default: () => null }));
vi.mock('react-router-dom', () => ({
useParams: () => ({ teamId: 'team-u1' }),
useLocation: () => ({ pathname: '/team/manage/team-u1' }),
}));

describe('TeamMembersPanel — unhandled-rejection guard (#REACT-10)', () => {
let urEvents: PromiseRejectionEvent[];
const urHandler = (e: PromiseRejectionEvent) => {
urEvents.push(e);
};

beforeEach(() => {
urEvents = [];
window.addEventListener('unhandledrejection', urHandler);
});

afterEach(() => {
window.removeEventListener('unhandledrejection', urHandler);
vi.clearAllMocks();
});

test('swallows refreshTeamMembers CoreRpcError(timeout) without unhandledrejection', async () => {
const refreshTeamMembers = vi
.fn()
.mockRejectedValue(
new CoreRpcError('Core RPC openhuman.team_list_members timed out after 30000ms', 'timeout')
);
vi.mocked(useCoreState).mockReturnValue({
snapshot: { currentUser: { _id: 'u1', activeTeamId: 'team-u1' } },
teams: [{ team: { _id: 'team-u1', name: 'T' }, role: 'ADMIN' }],
teamMembersById: {},
refreshTeamMembers,
} as never);

render(<TeamMembersPanel />);
await waitFor(() => expect(refreshTeamMembers).toHaveBeenCalledWith('team-u1'));
await new Promise(r => setTimeout(r, 20));

expect(urEvents).toHaveLength(0);
});
});
88 changes: 88 additions & 0 deletions app/src/components/settings/panels/__tests__/TeamPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* TeamPanel — unhandled-rejection guard tests.
*
* Regression coverage for OPENHUMAN-REACT-15 / REACT-11: bootstrap-time
* `team_list_teams` failures (cold core boot, backend 504, local
* AbortController timeout) used to leak as `void promise(...)` unhandled
* rejections via the `useEffect` mount handler. The new `.catch()` in
* `refreshTeamsWithLoading` plus the defensive `.catch()` on the
* `useEffect` invocation must absorb the rejection silently.
*/
import { render, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';

import { useCoreState } from '../../../../providers/CoreStateProvider';
import { CoreRpcError } from '../../../../services/coreRpcClient';
import TeamPanel from '../TeamPanel';

vi.mock('../../../../providers/CoreStateProvider', () => ({ useCoreState: vi.fn() }));
vi.mock('../../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
vi.mock('../../hooks/useSettingsNavigation', () => ({
useSettingsNavigation: () => ({
navigateBack: vi.fn(),
navigateToTeamManagement: vi.fn(),
breadcrumbs: [],
}),
}));
vi.mock('../../components/SettingsHeader', () => ({ default: () => null }));

describe('TeamPanel — unhandled-rejection guard (#REACT-15)', () => {
let urEvents: PromiseRejectionEvent[];
const urHandler = (e: PromiseRejectionEvent) => {
urEvents.push(e);
};

beforeEach(() => {
urEvents = [];
window.addEventListener('unhandledrejection', urHandler);
});

afterEach(() => {
window.removeEventListener('unhandledrejection', urHandler);
vi.clearAllMocks();
});

test('swallows refreshTeams CoreRpcError(timeout) without unhandledrejection', async () => {
const refreshTeams = vi
.fn()
.mockRejectedValue(
new CoreRpcError('Core RPC openhuman.team_list_teams timed out after 30000ms', 'timeout')
);
vi.mocked(useCoreState).mockReturnValue({
snapshot: { currentUser: { _id: 'u1', activeTeamId: 'team-u1' } },
teams: [],
refresh: vi.fn(),
refreshTeams,
} as never);

render(<TeamPanel />);

// Mount-effect must have fired refreshTeams and observed the rejection.
await waitFor(() => expect(refreshTeams).toHaveBeenCalled());
// Flush microtasks so the `.catch()` chain has a chance to settle.
await new Promise(r => setTimeout(r, 20));

expect(urEvents).toHaveLength(0);
});

test('swallows transport-kind refreshTeams failure without unhandledrejection', async () => {
// Backend 504 / connect-refused shape (REACT-13 / REACT-14 family) must
// also be absorbed — the `.catch()` is unconditional, not
// kind-gated.
const refreshTeams = vi
.fn()
.mockRejectedValue(new CoreRpcError('backend request GET /teams', 'transport'));
vi.mocked(useCoreState).mockReturnValue({
snapshot: { currentUser: { _id: 'u1', activeTeamId: 'team-u1' } },
teams: [],
refresh: vi.fn(),
refreshTeams,
} as never);

render(<TeamPanel />);
await waitFor(() => expect(refreshTeams).toHaveBeenCalled());
await new Promise(r => setTimeout(r, 20));

expect(urEvents).toHaveLength(0);
});
});
18 changes: 16 additions & 2 deletions app/src/providers/CoreStateProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -559,7 +559,15 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
const updateLocalState = useCallback(
async (params: Parameters<typeof updateCoreLocalState>[0]) => {
await updateCoreLocalState(params);
await refresh();
// The follow-up refresh is best-effort cache reconciliation, not part
// of the write contract — sibling helpers (setAnalyticsEnabled,
// setMeetAutoOrchestratorHandoff, …) already swallow here. An
// un-caught `app_state_snapshot` timeout used to bubble out of
// `setEncryptionKey` / `setOnboardingTasks` callers as an unhandled
// rejection → OPENHUMAN-REACT-Z/Y. The next poll tick will reconcile.
await refresh().catch(err => {
log('refresh failed after updateLocalState: %O', sanitizeError(err));
});
},
[refresh]
);
Expand All @@ -580,7 +588,13 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
// restartApp call here was redundant and skipped the persist purge,
// letting redux-persist rehydrate the prior user's slices on launch
// (#900). Restart now happens inside handleIdentityFlip after purge.
await refresh();
// Swallow refresh failures here so a cold-boot `app_state_snapshot`
// timeout post-login doesn't surface as an unhandled rejection
// (OPENHUMAN-REACT-Z/Y) — the polling loop reconciles within
// `POLL_MS`.
await refresh().catch(err => {
log('refresh failed after session store: %O', sanitizeError(err));
});
await refreshTeams().catch(err => {
log('refreshTeams failed after session store: %O', sanitizeError(err));
});
Expand Down
Loading
Loading