fix(settings): phone notification toggles save only the switch that was tapped

On the phone, every notification toggle sent the whole preference matrix as
it looked when the switch was tapped. With two toggles in flight the later
request could overwrite the earlier one on the server, and when the first
request failed the screen rolled that switch back while the second request
had already saved its value anyway.

The cause was the sendWholeMatrix option of useNotificationSettings, which
only the phone turned on. The server merges whatever cells it receives, so
the desktop already sent just the toggled cell. The option is gone and both
views send only the cell that changed.

Regression test FE-MOB-SETNOTIF-035 (MSettingsNotifications.test.tsx) was
red on the old code: "expected [ { trip_invite: { inapp: false,
webhook: false } }, { trip_invite: { inapp: false, webhook: true } } ] to
deeply equal [ { trip_invite: { inapp: false } }, { trip_invite:
{ webhook: true } } ]".

Changed assertions:
- useNotificationSettings.test.ts FE-COMP-NOTIFSETTINGS-004: the phone
  toggle body was { trip_invite: { inapp: false, email: false } }, now
  { trip_invite: { inapp: false } } (the hook is built with the phone's
  remaining option, skipMaskedToken).
- MSettingsNotifications.test.tsx FE-MOB-SETNOTIF-026: the PUT body was
  { trip_invite: { inapp: false, webhook: false } }, now
  { trip_invite: { inapp: false } }.
- MSettingsNotifications.test.tsx FE-MOB-SETNOTIF-032: the mock told the
  in-app write apart by body.trip_invite.webhook === false, which only the
  whole matrix carried; it now checks for the inapp key.
This commit is contained in:
Maurice
2026-10-09 17:25:20 +02:00
parent df7bb9a5e1
commit aa759ba54a
4 changed files with 48 additions and 38 deletions
@@ -77,12 +77,12 @@ describe('useNotificationSettings', () => {
expect(result.current.saving).toBe(false);
});
it('FE-COMP-NOTIFSETTINGS-004: the phone toggle sends the whole matrix', async () => {
it('FE-COMP-NOTIFSETTINGS-004: the phone toggle sends only the toggled cell too', async () => {
stubLoad();
const update = vi.spyOn(notificationsApi, 'updatePreferences').mockResolvedValue({});
const { result } = await loaded({ sendWholeMatrix: true });
const { result } = await loaded({ skipMaskedToken: true });
await act(() => result.current.toggle('trip_invite', 'inapp'));
expect(update).toHaveBeenCalledWith({ trip_invite: { inapp: false, email: false } });
expect(update).toHaveBeenCalledWith({ trip_invite: { inapp: false } });
expect(result.current.matrix?.preferences.trip_invite).toEqual({ inapp: false, email: false });
});
@@ -31,11 +31,6 @@ export interface PreferencesMatrix {
export const MASKED = '••••••••';
export interface NotificationSettingsOptions {
/**
* The phone sends the whole preference matrix on a toggle; the desktop sends only
* the toggled cell, so a second toggle in flight cannot be overwritten by it.
*/
sendWholeMatrix?: boolean;
/** The phone never sends an ntfy token that reads exactly like the mask. */
skipMaskedToken?: boolean;
}
@@ -45,10 +40,7 @@ export interface NotificationSettingsOptions {
* phone section render their own markup over this): the event/channel matrix, the
* webhook and ntfy credentials, and the test sends for every channel.
*/
export function useNotificationSettings({
sendWholeMatrix = false,
skipMaskedToken = false,
}: NotificationSettingsOptions = {}) {
export function useNotificationSettings({ skipMaskedToken = false }: NotificationSettingsOptions = {}) {
const { t } = useTranslation();
const toast = useToast();
const [matrix, setMatrix] = useState<PreferencesMatrix | null>(null);
@@ -128,28 +120,18 @@ export function useNotificationSettings({
const toggle = async (eventType: string, channel: string) => {
if (!matrix || isLockedCell(matrix.locked, eventType, channel)) return;
const current = matrix.preferences[eventType]?.[channel] ?? true;
let payload: Record<string, Record<string, boolean>>;
if (sendWholeMatrix) {
const updated = {
...matrix.preferences,
[eventType]: { ...matrix.preferences[eventType], [channel]: !current },
};
setMatrix((m) => (m ? { ...m, preferences: updated } : m));
payload = updated;
} else {
setMatrix((m) =>
m
? {
...m,
preferences: { ...m.preferences, [eventType]: { ...m.preferences[eventType], [channel]: !current } },
}
: m
);
// Only the toggled cell goes out. The server merges what it gets, and sending the
// whole matrix would carry this render's value for every other cell: a second
// toggle made while this request is in flight would be overwritten by it.
payload = { [eventType]: { [channel]: !current } };
}
setMatrix((m) =>
m
? {
...m,
preferences: { ...m.preferences, [eventType]: { ...m.preferences[eventType], [channel]: !current } },
}
: m
);
// Only the toggled cell goes out. The server merges what it gets, and sending the
// whole matrix would carry this render's value for every other cell: a second
// toggle made while this request is in flight would be overwritten by it.
const payload = { [eventType]: { [channel]: !current } };
setSaving(true);
try {
await notificationsApi.updatePreferences(payload);
@@ -21,7 +21,7 @@ export default function MSettingsNotifications() {
webhookUrl, setWebhookUrl, webhookIsSet, webhookSaving, webhookTesting, saveWebhookUrl, testWebhookUrl,
ntfyTopic, setNtfyTopic, ntfyServer, setNtfyServer, ntfyToken, setNtfyToken, ntfyTokenIsSet,
ntfySaving, ntfyTesting, saveNtfySettings, clearNtfyToken, testNtfySettings,
} = useNotificationSettings({ sendWholeMatrix: true, skipMaskedToken: true })
} = useNotificationSettings({ skipMaskedToken: true })
return (
<MSetCard title={t('settings.notifications')} icon={Bell}>
@@ -442,7 +442,7 @@ describe('MSettingsNotifications', () => {
});
describe('event matrix', () => {
it('FE-MOB-SETNOTIF-026: tapping a chip flips the preference and PUTs the whole matrix', async () => {
it('FE-MOB-SETNOTIF-026: tapping a chip flips the preference and PUTs only that cell', async () => {
const user = userEvent.setup();
let body: Record<string, Record<string, boolean>> | null = null;
server.use(
@@ -457,7 +457,7 @@ describe('MSettingsNotifications', () => {
await user.click(chip);
await waitFor(() => expect(body).not.toBeNull());
expect(body).toEqual({ trip_invite: { inapp: false, webhook: false } });
expect(body).toEqual({ trip_invite: { inapp: false } });
});
it('FE-MOB-SETNOTIF-027: a channel with no stored preference defaults to on', async () => {
@@ -511,7 +511,7 @@ describe('MSettingsNotifications', () => {
http.put('/api/notifications/preferences', async ({ request }) => {
const body = (await request.json()) as Record<string, Record<string, boolean>>;
// The first write is the in-app flip; it hangs until the test rejects it.
if (body.trip_invite.webhook === false) {
if ('inapp' in body.trip_invite) {
return new Promise<Response>(resolve => {
rejectInapp = () => resolve(HttpResponse.json({ error: 'nope' }, { status: 500 }) as unknown as Response);
});
@@ -541,6 +541,34 @@ describe('MSettingsNotifications', () => {
expect(screen.getByRole('button', { name: 'Webhook' }).className).toBe(activeClass);
});
it('FE-MOB-SETNOTIF-035: a second toggle while the first is in flight sends only its own cell', async () => {
const user = userEvent.setup();
const bodies: Record<string, Record<string, boolean>>[] = [];
let rejectFirst!: () => void;
server.use(
http.put('/api/notifications/preferences', async ({ request }) => {
bodies.push((await request.json()) as Record<string, Record<string, boolean>>);
if (bodies.length === 1) {
return new Promise<Response>(resolve => {
rejectFirst = () => resolve(HttpResponse.json({ error: 'nope' }, { status: 500 }) as unknown as Response);
});
}
return HttpResponse.json({ success: true });
}),
);
render(<MSettingsNotifications />);
await user.click(await screen.findByRole('button', { name: 'In-App' }));
await waitFor(() => expect(bodies).toHaveLength(1));
await user.click(screen.getByRole('button', { name: 'Webhook' }));
await waitFor(() => expect(bodies).toHaveLength(2));
rejectFirst();
// The failed in-app flip is rolled back on screen, so the server must not get it
// through the webhook write either.
expect(bodies).toEqual([{ trip_invite: { inapp: false } }, { trip_invite: { webhook: true } }]);
});
it('FE-MOB-SETNOTIF-029: the saving hint shows while the update is in flight', async () => {
const user = userEvent.setup();
let release!: () => void;