Skip to main content

metaxy_cli/codegen/
react.rs

1use 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
275/// Generates the complete React reactive wrapper file content from a manifest.
276///
277/// Returns an empty string when the manifest contains no procedures (the caller
278/// should skip writing the file in that case).
279pub 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}