metaxy_cli/codegen/
react.rs1use crate::model::Manifest;
2
3use super::common::{self, FrameworkConfig};
4
5const QUERY_OPTIONS_INTERFACE: &str = r#"export interface QueryOptions<K extends QueryKey> {
6 /**
7 * Whether to execute the query. @default true
8 *
9 * Pass a getter `() => bool` for reactive updates — a plain `boolean` is
10 * read once when `useQuery` is called and will not trigger re-fetches.
11 */
12 enabled?: boolean | (() => boolean);
13
14 /** Auto-refetch interval in milliseconds. Set to 0 or omit to disable. */
15 refetchInterval?: number;
16
17 /** Initial data shown before the first fetch completes. */
18 placeholderData?: QueryOutput<K>;
19
20 /** Per-call options forwarded to client.query(). */
21 callOptions?: CallOptions;
22
23 /** Called when the query succeeds. */
24 onSuccess?: (data: QueryOutput<K>) => void;
25
26 /** Called when the query fails. */
27 onError?: (error: RpcError) => void;
28
29 /** Called when the query settles (success or failure). */
30 onSettled?: () => void;
31}"#;
32
33const QUERY_RESULT_INTERFACE: &str = r#"export interface QueryResult<K extends QueryKey> {
34 /** The latest successfully resolved data, or placeholderData. */
35 readonly data: QueryOutput<K> | undefined;
36
37 /** The error from the most recent failed fetch, cleared on next attempt. */
38 readonly error: RpcError | undefined;
39
40 /** True while a fetch is in-flight (including the initial fetch). */
41 readonly isLoading: boolean;
42
43 /** True after the first successful fetch. Stays true even if a later refetch fails. */
44 readonly isSuccess: boolean;
45
46 /** True when the most recent fetch failed. */
47 readonly isError: boolean;
48
49 /** Manually trigger a refetch. No-op when `enabled` is false. */
50 refetch: () => Promise<void>;
51}"#;
52
53const MUTATION_OPTIONS_INTERFACE: &str = r#"export interface MutationOptions<K extends MutationKey> {
54 /** Per-call options forwarded to client.mutate(). */
55 callOptions?: CallOptions;
56
57 /** Called when the mutation succeeds. */
58 onSuccess?: (data: MutationOutput<K>) => void;
59
60 /** Called when the mutation fails. */
61 onError?: (error: RpcError) => void;
62
63 /** Called when the mutation settles (success or failure). */
64 onSettled?: () => void;
65}"#;
66
67const MUTATION_RESULT_INTERFACE: &str = r#"export interface MutationResult<K extends MutationKey> {
68 /** Execute the mutation. Rejects on error. */
69 mutate: (...args: MutationArgs<K>) => Promise<void>;
70
71 /** Execute the mutation and return the result. Rejects on error. */
72 mutateAsync: (...args: MutationArgs<K>) => Promise<MutationOutput<K>>;
73
74 /** The latest successfully resolved data. */
75 readonly data: MutationOutput<K> | undefined;
76
77 /** The error from the most recent failed mutation, cleared on next attempt. */
78 readonly error: RpcError | undefined;
79
80 /** True while a mutation is in-flight. */
81 readonly isLoading: boolean;
82
83 /** True after the most recent mutation succeeded. */
84 readonly isSuccess: boolean;
85
86 /** True when error is set. */
87 readonly isError: boolean;
88
89 /** Reset state back to idle (clear data, error, status). */
90 reset: () => void;
91}"#;
92
93const USE_QUERY_IMPL: &str = r#"export function useQuery<K extends QueryKey>(
94 client: RpcClient,
95 ...args: unknown[]
96): QueryResult<K> {
97 const key = args[0] as K;
98
99 let input: QueryInput<K> | undefined;
100 let optionsArg: QueryOptions<K> | (() => QueryOptions<K>) | undefined;
101
102 if (VOID_QUERY_KEYS.has(key)) {
103 optionsArg = args[1] as QueryOptions<K> | (() => QueryOptions<K>) | undefined;
104 } else {
105 input = args[1] as QueryInput<K>;
106 optionsArg = args[2] as QueryOptions<K> | (() => QueryOptions<K>) | undefined;
107 }
108
109 const optionsArgRef = useRef(optionsArg);
110 optionsArgRef.current = optionsArg;
111
112 const resolveOptions = useCallback((): QueryOptions<K> | undefined =>
113 typeof optionsArgRef.current === "function"
114 ? optionsArgRef.current()
115 : optionsArgRef.current,
116 []);
117
118 function resolveEnabled(): boolean {
119 const opts = resolveOptions();
120 return typeof opts?.enabled === "function"
121 ? opts.enabled()
122 : (opts?.enabled ?? true);
123 }
124
125 const [data, setData] = useState<QueryOutput<K> | undefined>(() => resolveOptions()?.placeholderData);
126 const [error, setError] = useState<RpcError | undefined>();
127 const [isLoading, setIsLoading] = useState(resolveEnabled);
128 const [hasFetched, setHasFetched] = useState(false);
129
130 const generationRef = useRef(0);
131 const controllerRef = useRef<AbortController | undefined>();
132 const inputRef = useRef(input);
133 inputRef.current = input;
134 const serializedInput = JSON.stringify(input);
135
136 const fetchData = useCallback(async (
137 inputVal: QueryInput<K> | undefined,
138 signal: AbortSignal,
139 gen: number,
140 ) => {
141 const opts = resolveOptions();
142 setIsLoading(true);
143 setError(undefined);
144 try {
145 const callArgs: unknown[] = [key];
146 if (inputVal !== undefined) callArgs.push(inputVal);
147 const mergedCallOptions = { ...opts?.callOptions, signal: opts?.callOptions?.signal
148 ? AbortSignal.any([signal, opts.callOptions.signal])
149 : signal };
150 callArgs.push(mergedCallOptions);
151 const result = await (client.query as (...a: unknown[]) => Promise<unknown>)(
152 ...callArgs
153 ) as QueryOutput<K>;
154 if (gen !== generationRef.current) return;
155 setData(result);
156 setHasFetched(true);
157 opts?.onSuccess?.(result);
158 } catch (e) {
159 if (gen !== generationRef.current) return;
160 const err = e as RpcError;
161 setError(err);
162 opts?.onError?.(err);
163 } finally {
164 if (gen === generationRef.current) {
165 setIsLoading(false);
166 opts?.onSettled?.();
167 }
168 }
169 }, [client, key, resolveOptions]);
170
171 const enabled = resolveEnabled();
172 const refetchInterval = resolveOptions()?.refetchInterval;
173
174 useEffect(() => {
175 if (!enabled) {
176 setIsLoading(false);
177 return;
178 }
179
180 generationRef.current++;
181 const gen = generationRef.current;
182 const controller = new AbortController();
183 controllerRef.current = controller;
184 void fetchData(inputRef.current, controller.signal, gen);
185
186 let interval: ReturnType<typeof setInterval> | undefined;
187 if (refetchInterval) {
188 interval = setInterval(() => {
189 const ctrl = controllerRef.current;
190 if (ctrl && !ctrl.signal.aborted) {
191 void fetchData(inputRef.current, ctrl.signal, generationRef.current);
192 }
193 }, refetchInterval);
194 }
195
196 return () => {
197 controller.abort();
198 if (interval) clearInterval(interval);
199 };
200 }, [fetchData, enabled, serializedInput, refetchInterval]);
201
202 return {
203 data, error, isLoading,
204 isSuccess: hasFetched,
205 isError: error !== undefined,
206 refetch: async () => {
207 if (!resolveEnabled()) return;
208 generationRef.current++;
209 const gen = generationRef.current;
210 const localController = new AbortController();
211 if (controllerRef.current) controllerRef.current.abort();
212 controllerRef.current = localController;
213 await fetchData(inputRef.current, localController.signal, gen);
214 },
215 };
216}"#;
217
218const USE_MUTATION_IMPL: &str = r#"export function useMutation<K extends MutationKey>(
219 client: RpcClient,
220 key: K,
221 options?: MutationOptions<K>,
222): MutationResult<K> {
223 const [data, setData] = useState<MutationOutput<K> | undefined>();
224 const [error, setError] = useState<RpcError | undefined>();
225 const [isLoading, setIsLoading] = useState(false);
226 const [hasSucceeded, setHasSucceeded] = useState(false);
227
228 const optionsRef = useRef(options);
229 optionsRef.current = options;
230
231 const execute = useCallback(async (...input: MutationArgs<K>): Promise<MutationOutput<K>> => {
232 setIsLoading(true);
233 setError(undefined);
234 setHasSucceeded(false);
235 try {
236 const callArgs: unknown[] = [key];
237 if (input.length > 0) callArgs.push(input[0]);
238 if (optionsRef.current?.callOptions) callArgs.push(optionsRef.current.callOptions);
239 const result = await (client.mutate as (...a: unknown[]) => Promise<unknown>)(...callArgs) as MutationOutput<K>;
240 setData(result);
241 setHasSucceeded(true);
242 optionsRef.current?.onSuccess?.(result);
243 return result;
244 } catch (e) {
245 const err = e as RpcError;
246 setError(err);
247 optionsRef.current?.onError?.(err);
248 throw e;
249 } finally {
250 setIsLoading(false);
251 optionsRef.current?.onSettled?.();
252 }
253 }, [client, key]);
254
255 const reset = useCallback(() => {
256 setData(undefined);
257 setError(undefined);
258 setIsLoading(false);
259 setHasSucceeded(false);
260 }, []);
261
262 return {
263 mutate: async (...args: MutationArgs<K>) => { await execute(...args); },
264 mutateAsync: (...args: MutationArgs<K>) => execute(...args),
265 data, error, isLoading,
266 isSuccess: hasSucceeded,
267 isError: error !== undefined,
268 reset,
269 };
270}"#;
271
272const FRAMEWORK_IMPORT: &str =
273 "import { useState, useEffect, useRef, useCallback } from \"react\";";
274
275pub fn generate_react_file(
280 manifest: &Manifest,
281 client_import_path: &str,
282 types_import_path: &str,
283 preserve_docs: bool,
284) -> String {
285 common::generate_framework_file(
286 manifest,
287 client_import_path,
288 types_import_path,
289 preserve_docs,
290 &FrameworkConfig {
291 framework_import: Some(FRAMEWORK_IMPORT),
292 query_fn_name: "useQuery",
293 input_as_getter: false,
294 query_interfaces: &[QUERY_OPTIONS_INTERFACE, QUERY_RESULT_INTERFACE],
295 mutation_interfaces: &[MUTATION_OPTIONS_INTERFACE, MUTATION_RESULT_INTERFACE],
296 query_impl: USE_QUERY_IMPL,
297 mutation_impl: USE_MUTATION_IMPL,
298 },
299 )
300}