Skip to content

Commit aa409da

Browse files
committed
feat: useOptimisticMutation
1 parent f7792fd commit aa409da

2 files changed

Lines changed: 261 additions & 0 deletions

File tree

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
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+
invalidateKey: SETTINGS_STORE_KEY,
68+
});
69+
70+
// --- Semantic actions ---
71+
const { mutate } = mutation;
72+
73+
const resetHotKeys = useCallback(() => {
74+
mutate({ hotKeyRegistry: newDefaultRegistry() });
75+
}, [mutate]);
76+
77+
const resetSingleHotKey = useCallback(
78+
(shortcut: KeyboardShortcut) => {
79+
mutateResult((settings: Settings) => {
80+
if (!settings?.hotKeyRegistry) {
81+
return settings;
82+
}
83+
const hotKeyRegistry = { ...settings.hotKeyRegistry, [shortcut]: newDefaultRegistry()[shortcut] };
84+
return { hotKeyRegistry };
85+
});
86+
},
87+
[mutateResult],
88+
);
89+
90+
const addKeyCombination = useCallback(
91+
(shortcut: KeyboardShortcut, keyComb: KeyCombination) => {
92+
mutateResult((settings: Settings) => {
93+
if (!settings?.hotKeyRegistry) {
94+
return settings;
95+
}
96+
97+
const hotKeyRegistry = structuredClone(settings.hotKeyRegistry);
98+
const keyCombs = getPlatformKeyCombinations(hotKeyRegistry[shortcut]);
99+
keyCombs.push(keyComb);
100+
return { hotKeyRegistry };
101+
});
102+
},
103+
[mutateResult],
104+
);
105+
106+
const removeKeyCombination = useCallback(
107+
(shortcut: KeyboardShortcut, keyComb: KeyCombination) => {
108+
mutateResult((settings: Settings) => {
109+
if (!settings?.hotKeyRegistry) {
110+
return settings;
111+
}
112+
const hotKeyRegistry = structuredClone(settings.hotKeyRegistry);
113+
const keyCombs = getPlatformKeyCombinations(hotKeyRegistry[shortcut]);
114+
const idx = keyCombs.findIndex(
115+
k =>
116+
k.keyCode === keyComb.keyCode &&
117+
Boolean(k.alt) === Boolean(keyComb.alt) &&
118+
Boolean(k.shift) === Boolean(keyComb.shift) &&
119+
Boolean(k.ctrl) === Boolean(keyComb.ctrl) &&
120+
Boolean(k.meta) === Boolean(keyComb.meta),
121+
);
122+
if (idx !== -1) {
123+
keyCombs.splice(idx, 1);
124+
return { hotKeyRegistry };
125+
}
126+
return settings;
127+
});
128+
},
129+
[mutateResult],
130+
);
131+
132+
const toggleVariableSourceAndValue = useCallback(() => {
133+
mutateResult((settings: Settings) => {
134+
if (!settings) {
135+
return settings;
136+
}
137+
return { showVariableSourceAndValue: !settings.showVariableSourceAndValue };
138+
});
139+
}, [mutateResult]);
140+
141+
const togglePlugin = useCallback(
142+
(pluginName: string, enabled: boolean) => {
143+
mutateResult((settings: Settings) => {
144+
if (!settings?.pluginConfig) {
145+
return settings;
146+
}
147+
const pluginConfig: PluginConfigMap = {
148+
...settings.pluginConfig,
149+
[pluginName]: { ...settings.pluginConfig[pluginName], disabled: !enabled },
150+
};
151+
return { pluginConfig };
152+
});
153+
},
154+
[mutateResult],
155+
);
156+
157+
const toggleBulkHeaderEditor = useCallback(() => {
158+
mutateResult((settings: Settings) => {
159+
if (!settings) {
160+
return settings;
161+
}
162+
return { useBulkHeaderEditor: !settings.useBulkHeaderEditor };
163+
});
164+
}, [mutateResult]);
165+
166+
const toggleBulkParametersEditor = useCallback(() => {
167+
mutateResult((settings: Settings) => {
168+
if (!settings) {
169+
return settings;
170+
}
171+
return { useBulkParametersEditor: !settings.useBulkParametersEditor };
172+
});
173+
}, [mutateResult]);
174+
175+
useDBListener();
176+
177+
return {
178+
settings,
179+
...rest,
180+
// computed
181+
httpProxyHasCredentials,
182+
isProxyConfigured,
183+
hasCustomPluginPath,
184+
hasDataFolderRestrictions,
185+
// generic update
186+
mutation,
187+
// semantic actions
188+
resetHotKeys,
189+
resetSingleHotKey,
190+
addKeyCombination,
191+
removeKeyCombination,
192+
toggleVariableSourceAndValue,
193+
togglePlugin,
194+
toggleBulkHeaderEditor,
195+
toggleBulkParametersEditor,
196+
};
197+
}
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)