Skip to content
Open
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
9 changes: 9 additions & 0 deletions .changeset/fix-dashboard-mutation-race.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@hyperdx/app": patch
---

Fix remote dashboard changes being silently lost when two edits are saved in
quick succession. `useUpdateDashboard` now optimistically updates the
`dashboards` query cache in `onMutate` (with rollback on error), so a following
`setDashboard` derives its update from the latest state instead of a stale
pre-mutation snapshot whose PATCH would overwrite the earlier change.
130 changes: 130 additions & 0 deletions packages/app/src/__tests__/dashboard.optimistic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
jest.mock('../config', () => ({ IS_LOCAL_MODE: false }));
jest.mock('../api', () => ({ hdxServer: jest.fn() }));
jest.mock('@mantine/notifications', () => ({
notifications: { show: jest.fn() },
}));
jest.mock('nuqs', () => ({
parseAsJson: jest.fn(),
useQueryState: jest.fn(),
}));

type MutationConfig = {
onMutate?: (input: any) => any;
onError?: (error: unknown, input: any, context: any) => void;
onSettled?: () => void;
};

const mutationConfigs: MutationConfig[] = [];

// A minimal in-memory query cache so getQueryData/setQueryData actually
// share state, letting a test prove two sequential onMutate calls compose
// instead of clobbering each other.
const store = new Map<string, unknown>();
const keyOf = (key: unknown) => JSON.stringify(key);

const queryClient = {
cancelQueries: jest.fn().mockResolvedValue(undefined),
invalidateQueries: jest.fn(),
getQueryData: jest.fn((key: unknown) => store.get(keyOf(key))),
setQueryData: jest.fn((key: unknown, updater: unknown) => {
const current = store.get(keyOf(key));
const next =
typeof updater === 'function'
? (updater as (c: unknown) => unknown)(current)
: updater;
store.set(keyOf(key), next);
return next;
}),
};

jest.mock('@tanstack/react-query', () => ({
useQuery: jest.fn(),
useMutation: jest.fn((cfg: MutationConfig) => {
mutationConfigs.push(cfg);
return { mutate: jest.fn(), mutateAsync: jest.fn() };
}),
useQueryClient: jest.fn(() => queryClient),
}));
jest.mock('@/utils', () => ({ hashCode: jest.fn(() => 0) }));

import { useUpdateDashboard } from '@/dashboard';

const captureUpdateConfig = (
hookFactory: () => unknown = useUpdateDashboard,
): MutationConfig => {
hookFactory();
return mutationConfigs[mutationConfigs.length - 1];
};

const seedCache = (dashboards: unknown[]) =>
store.set(keyOf(['dashboards']), dashboards);

beforeEach(() => {
mutationConfigs.length = 0;
store.clear();
queryClient.cancelQueries.mockClear();
queryClient.invalidateQueries.mockClear();
queryClient.getQueryData.mockClear();
queryClient.setQueryData.mockClear();
});

const cached = () => store.get(keyOf(['dashboards'])) as any[];

describe('useUpdateDashboard optimistic cache update', () => {
it('writes the pending change into the dashboards cache under the dashboards key', async () => {
const cfg = captureUpdateConfig();
seedCache([
{ id: 'a', name: 'A', tiles: [], tags: [], bordered: false },
{ id: 'b', name: 'B', tiles: [], tags: [] },
]);

await cfg.onMutate?.({ id: 'a', bordered: true });

expect(queryClient.cancelQueries).toHaveBeenCalledWith({
queryKey: ['dashboards'],
});
expect(queryClient.setQueryData.mock.calls[0][0]).toEqual(['dashboards']);
expect(cached().find(d => d.id === 'a').bordered).toBe(true);
expect(cached().find(d => d.id === 'b').name).toBe('B');
});

it('composes two sequential edits so neither clobbers the other (#2216)', async () => {
const cfg = captureUpdateConfig();
seedCache([{ id: 'a', name: 'A', tiles: [], tags: [], bordered: false }]);

// Edit 1: toggle bordered. Edit 2 reads the already-updated cache.
await cfg.onMutate?.({ id: 'a', bordered: true });
await cfg.onMutate?.({ id: 'a', name: 'Renamed' });

const dashboard = cached().find(d => d.id === 'a');
expect(dashboard.bordered).toBe(true);
expect(dashboard.name).toBe('Renamed');
});

it('leaves an empty cache untouched instead of writing undefined', async () => {
const cfg = captureUpdateConfig();
// No seedCache: the query has not resolved yet.
await cfg.onMutate?.({ id: 'a', bordered: true });
expect(store.get(keyOf(['dashboards']))).toBeUndefined();
});

it('rolls the cache back to the previous snapshot on error', async () => {
const cfg = captureUpdateConfig();
const previous = [{ id: 'a', name: 'A', tiles: [], tags: [] }];
seedCache(previous);

const context = await cfg.onMutate?.({ id: 'a', name: 'changed' });
expect(cached()[0].name).toBe('changed');

cfg.onError?.(new Error('boom'), { id: 'a' }, context);
expect(cached()).toEqual(previous);
});

it('invalidates dashboards after settling', () => {
const cfg = captureUpdateConfig();
cfg.onSettled?.();
expect(queryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: ['dashboards'],
});
});
});
26 changes: 25 additions & 1 deletion packages/app/src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,31 @@ export function useUpdateDashboard() {
json: normalized,
});
},
onSuccess: () => {
onMutate: async (
dashboard: Partial<Dashboard> & { id: Dashboard['id'] },
) => {
await queryClient.cancelQueries({ queryKey: ['dashboards'] });
const previousDashboards = queryClient.getQueryData<Dashboard[]>([
'dashboards',
]);
queryClient.setQueryData<Dashboard[]>(['dashboards'], current =>
current
? current.map(d =>
d.id === dashboard.id ? { ...d, ...dashboard } : d,
)
: current,
);
return { previousDashboards };
},
onError: (_error, _dashboard, context) => {
if (context?.previousDashboards) {
queryClient.setQueryData<Dashboard[]>(
['dashboards'],
context.previousDashboards,
);
}
},
onSettled: () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @rachit367, thanks for looking into this. However I don't think this fully solves the problem, since intermediate dashboard states are displayed when PATCH responses are received.

Consider the following scenario:

  1. User makes edit A
  2. User makes edit B (correctly applied on top of edit A with these changes)
  3. UI receives updated dashboards data after edit A - UI now displays dashboard with edit A applied but not edit B
  4. User makes edit C (this is incorrectly applied on top of edit A but not edit B, since the react query cache contains data from step (3) and not step (2).
  5. UI receives updated dashboards data after edit B (includes edit A + B)
  6. UI receives updated dashboards data after edit C (includes edit A + C, but not B)

Edit B in this case is clobbered.

Reproduction shown in this video, where I've added a 5s artificial delay in the PATCH dashboard endpoint to help illustrate the issue:

Screen.Recording.2026-07-07.at.12.56.23.PM.mov

We can probably fix this by preventing invalidation in onSettled during an ongoing mutation, as the deep-review points out above. There is an example of this in https://github.com/rachit367/hyperdx/blob/claude/fix-dashboard-mutation-race/packages/app/src/favorites.ts

queryClient.invalidateQueries({ queryKey: ['dashboards'] });
},
});
Expand Down
Loading