Skip to content

Commit b2977c7

Browse files
committed
feat: useOptimisticMutation
1 parent f7792fd commit b2977c7

2 files changed

Lines changed: 260 additions & 0 deletions

File tree

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { useQuery, useQueryClient } from '@tanstack/react-query';
2+
import { useCallback, useEffect } from 'react';
3+
4+
import type { ChangeBufferEvent, Settings } from '~/insomnia-data';
5+
import { models, services } from '~/insomnia-data';
6+
import type { KeyboardShortcut, KeyCombination, PluginConfigMap } from '~/insomnia-data/common';
7+
import { getPlatformKeyCombinations, newDefaultRegistry } from '~/insomnia-data/common';
8+
import { AnalyticsEvent } from '~/ui/analytics';
9+
import { useOptimisticMutation } from '~/ui/stores/use-optimistic-mutation';
10+
11+
export const SETTINGS_STORE_KEY = ['settings'];
12+
13+
let registerCount = 0;
14+
let unsubscribe: (() => void) | null = null;
15+
16+
function useDBListener() {
17+
const queryClient = useQueryClient();
18+
19+
useEffect(() => {
20+
registerCount += 1;
21+
22+
if (registerCount === 1) {
23+
unsubscribe = window.main.on('db.changes', (_, changes: ChangeBufferEvent[]) => {
24+
const hasSettingsChange = changes.some(([_, doc]) => doc.type === models.settings.type);
25+
if (hasSettingsChange) {
26+
queryClient.invalidateQueries({ queryKey: SETTINGS_STORE_KEY });
27+
}
28+
});
29+
}
30+
31+
return () => {
32+
registerCount -= 1;
33+
34+
if (registerCount === 0 && unsubscribe) {
35+
unsubscribe();
36+
unsubscribe = null;
37+
}
38+
};
39+
}, [queryClient]);
40+
}
41+
42+
export function useSettingsStore(selector?: (settings: Settings) => any) {
43+
const { data: settings, ...rest } = useQuery({
44+
queryKey: SETTINGS_STORE_KEY,
45+
queryFn: async () => {
46+
const settings = await services.settings.get();
47+
return settings;
48+
},
49+
selector,
50+
});
51+
52+
// computed
53+
const httpProxyHasCredentials = settings?.httpProxy?.includes('@');
54+
const isProxyConfigured = Boolean(settings?.proxyEnabled && (settings.httpProxy || settings.httpsProxy));
55+
const hasCustomPluginPath = Boolean(settings?.pluginPath);
56+
const hasDataFolderRestrictions = Boolean(settings?.dataFolders && settings.dataFolders.length > 0);
57+
58+
const { mutateResult, ...mutation } = useOptimisticMutation({
59+
mutationFn: async (patch: Partial<Settings>, { client }) => {
60+
const updatedSettings = await services.settings.patch(patch);
61+
client.setQueryData(SETTINGS_STORE_KEY, updatedSettings);
62+
if ('enableAnalytics' in patch && !patch.enableAnalytics) {
63+
window.main.trackAnalyticsEvent({ event: AnalyticsEvent.analyticsDisabled });
64+
}
65+
return updatedSettings;
66+
},
67+
});
68+
69+
// --- Semantic actions ---
70+
const { mutate } = mutation;
71+
72+
const resetHotKeys = useCallback(() => {
73+
mutate({ hotKeyRegistry: newDefaultRegistry() });
74+
}, [mutate]);
75+
76+
const resetSingleHotKey = useCallback(
77+
(shortcut: KeyboardShortcut) => {
78+
mutateResult((settings: Settings) => {
79+
if (!settings?.hotKeyRegistry) {
80+
return settings;
81+
}
82+
const hotKeyRegistry = { ...settings.hotKeyRegistry, [shortcut]: newDefaultRegistry()[shortcut] };
83+
return { hotKeyRegistry };
84+
});
85+
},
86+
[mutateResult],
87+
);
88+
89+
const addKeyCombination = useCallback(
90+
(shortcut: KeyboardShortcut, keyComb: KeyCombination) => {
91+
mutateResult((settings: Settings) => {
92+
if (!settings?.hotKeyRegistry) {
93+
return settings;
94+
}
95+
96+
const hotKeyRegistry = structuredClone(settings.hotKeyRegistry);
97+
const keyCombs = getPlatformKeyCombinations(hotKeyRegistry[shortcut]);
98+
keyCombs.push(keyComb);
99+
return { hotKeyRegistry };
100+
});
101+
},
102+
[mutateResult],
103+
);
104+
105+
const removeKeyCombination = useCallback(
106+
(shortcut: KeyboardShortcut, keyComb: KeyCombination) => {
107+
mutateResult((settings: Settings) => {
108+
if (!settings?.hotKeyRegistry) {
109+
return settings;
110+
}
111+
const hotKeyRegistry = structuredClone(settings.hotKeyRegistry);
112+
const keyCombs = getPlatformKeyCombinations(hotKeyRegistry[shortcut]);
113+
const idx = keyCombs.findIndex(
114+
k =>
115+
k.keyCode === keyComb.keyCode &&
116+
Boolean(k.alt) === Boolean(keyComb.alt) &&
117+
Boolean(k.shift) === Boolean(keyComb.shift) &&
118+
Boolean(k.ctrl) === Boolean(keyComb.ctrl) &&
119+
Boolean(k.meta) === Boolean(keyComb.meta),
120+
);
121+
if (idx !== -1) {
122+
keyCombs.splice(idx, 1);
123+
return { hotKeyRegistry };
124+
}
125+
return settings;
126+
});
127+
},
128+
[mutateResult],
129+
);
130+
131+
const toggleVariableSourceAndValue = useCallback(() => {
132+
mutateResult((settings: Settings) => {
133+
if (!settings) {
134+
return settings;
135+
}
136+
return { showVariableSourceAndValue: !settings.showVariableSourceAndValue };
137+
});
138+
}, [mutateResult]);
139+
140+
const togglePlugin = useCallback(
141+
(pluginName: string, enabled: boolean) => {
142+
mutateResult((settings: Settings) => {
143+
if (!settings?.pluginConfig) {
144+
return settings;
145+
}
146+
const pluginConfig: PluginConfigMap = {
147+
...settings.pluginConfig,
148+
[pluginName]: { ...settings.pluginConfig[pluginName], disabled: !enabled },
149+
};
150+
return { pluginConfig };
151+
});
152+
},
153+
[mutateResult],
154+
);
155+
156+
const toggleBulkHeaderEditor = useCallback(() => {
157+
mutateResult((settings: Settings) => {
158+
if (!settings) {
159+
return settings;
160+
}
161+
return { useBulkHeaderEditor: !settings.useBulkHeaderEditor };
162+
});
163+
}, [mutateResult]);
164+
165+
const toggleBulkParametersEditor = useCallback(() => {
166+
mutateResult((settings: Settings) => {
167+
if (!settings) {
168+
return settings;
169+
}
170+
return { useBulkParametersEditor: !settings.useBulkParametersEditor };
171+
});
172+
}, [mutateResult]);
173+
174+
useDBListener();
175+
176+
return {
177+
settings,
178+
...rest,
179+
// computed
180+
httpProxyHasCredentials,
181+
isProxyConfigured,
182+
hasCustomPluginPath,
183+
hasDataFolderRestrictions,
184+
// generic update
185+
mutation,
186+
// semantic actions
187+
resetHotKeys,
188+
resetSingleHotKey,
189+
addKeyCombination,
190+
removeKeyCombination,
191+
toggleVariableSourceAndValue,
192+
togglePlugin,
193+
toggleBulkHeaderEditor,
194+
toggleBulkParametersEditor,
195+
};
196+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { useMutation, useQueryClient } from '@tanstack/react-query';
2+
import { useCallback } from 'react';
3+
4+
export type Variables<T> = Partial<T> | ((variables: T) => Promise<Partial<T>> | Partial<T>);
5+
6+
export function useOptimisticMutation(
7+
options: Parameters<typeof useMutation>[0] & { invalidateKey: any[]; patch: boolean },
8+
) {
9+
const { invalidateKey, mutationFn, onMutate, onSettled, patch, ...rest } = options;
10+
const mutation = useMutation({
11+
...rest,
12+
mutationFn: (...args: any) => {
13+
const [variables, { client }] = args;
14+
const previousSettings = client.getQueryData(invalidateKey);
15+
client.setQueryData(invalidateKey, patch ? { ...previousSettings, ...variables } : variables);
16+
17+
return mutationFn?.(...args);
18+
},
19+
// before the mutation
20+
onMutate: (...args: any) => {
21+
const [_, { client }] = args;
22+
const previous = client.getQueryData(invalidateKey);
23+
const result = onMutate?.(...args) || {};
24+
return { _previous: previous, ...result };
25+
},
26+
onSettled: (...args: any) => {
27+
const [data, error, variables, { _previous }, { client }] = args;
28+
if (error && _previous) {
29+
// rollback first
30+
client.setQueryData(invalidateKey, _previous);
31+
}
32+
onSettled?.(...args);
33+
// Always refetch after error or success:
34+
client.invalidateQueries({ queryKey: invalidateKey });
35+
},
36+
});
37+
38+
const client = useQueryClient();
39+
const { mutate } = mutation;
40+
41+
const mutateResult = useCallback(
42+
<T>(doMutation: Variables<T>) => {
43+
const settings = client.getQueryData(invalidateKey);
44+
let result;
45+
if (typeof doMutation === 'function') {
46+
result = doMutation(settings);
47+
if (result instanceof Promise) {
48+
result.then(res => {
49+
if (settings !== res) {
50+
mutate(res);
51+
}
52+
});
53+
return;
54+
}
55+
}
56+
if (settings !== result) {
57+
mutate(result);
58+
}
59+
},
60+
[client, mutate, invalidateKey], // invalidateKey is not expected to change, but we include it for completeness
61+
);
62+
63+
return { ...mutation, mutateResult };
64+
}

0 commit comments

Comments
 (0)