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. Works even 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      generationRef.current++;
208      const gen = generationRef.current;
209      const localController = new AbortController();
210      if (controllerRef.current) controllerRef.current.abort();
211      controllerRef.current = localController;
212      await fetchData(inputRef.current, localController.signal, gen);
213    },
214  };
215}"#;
216
217const USE_MUTATION_IMPL: &str = r#"export function useMutation<K extends MutationKey>(
218  client: RpcClient,
219  key: K,
220  options?: MutationOptions<K>,
221): MutationResult<K> {
222  const [data, setData] = useState<MutationOutput<K> | undefined>();
223  const [error, setError] = useState<RpcError | undefined>();
224  const [isLoading, setIsLoading] = useState(false);
225  const [hasSucceeded, setHasSucceeded] = useState(false);
226
227  const optionsRef = useRef(options);
228  optionsRef.current = options;
229
230  const execute = useCallback(async (...input: MutationArgs<K>): Promise<MutationOutput<K>> => {
231    setIsLoading(true);
232    setError(undefined);
233    setHasSucceeded(false);
234    try {
235      const callArgs: unknown[] = [key];
236      if (input.length > 0) callArgs.push(input[0]);
237      if (optionsRef.current?.callOptions) callArgs.push(optionsRef.current.callOptions);
238      const result = await (client.mutate as (...a: unknown[]) => Promise<unknown>)(...callArgs) as MutationOutput<K>;
239      setData(result);
240      setHasSucceeded(true);
241      optionsRef.current?.onSuccess?.(result);
242      return result;
243    } catch (e) {
244      const err = e as RpcError;
245      setError(err);
246      optionsRef.current?.onError?.(err);
247      throw e;
248    } finally {
249      setIsLoading(false);
250      optionsRef.current?.onSettled?.();
251    }
252  }, [client, key]);
253
254  const reset = useCallback(() => {
255    setData(undefined);
256    setError(undefined);
257    setIsLoading(false);
258    setHasSucceeded(false);
259  }, []);
260
261  return {
262    mutate: async (...args: MutationArgs<K>) => { await execute(...args); },
263    mutateAsync: (...args: MutationArgs<K>) => execute(...args),
264    data, error, isLoading,
265    isSuccess: hasSucceeded,
266    isError: error !== undefined,
267    reset,
268  };
269}"#;
270
271const STREAM_OPTIONS_INTERFACE: &str = r#"export interface StreamOptions<K extends StreamKey> {
272  callOptions?: CallOptions;
273  onChunk?: (chunk: StreamOutput<K>) => void;
274  onDone?: () => void;
275  onError?: (error: RpcError) => void;
276}"#;
277
278const STREAM_RESULT_INTERFACE: &str = r#"export interface StreamResult<K extends StreamKey> {
279  readonly chunks: StreamOutput<K>[];
280  readonly error: RpcError | undefined;
281  readonly isStreaming: boolean;
282  readonly isDone: boolean;
283  start: () => void;
284  stop: () => void;
285}"#;
286
287const USE_STREAM_IMPL: &str = r#"export function useStream<K extends StreamKey>(
288  client: RpcClient,
289  ...args: unknown[]
290): StreamResult<K> {
291  const key = args[0] as K;
292
293  let inputArg: StreamInput<K> | undefined;
294  let options: StreamOptions<K> | undefined;
295
296  if (VOID_STREAM_KEYS.has(key)) {
297    options = args[1] as StreamOptions<K> | undefined;
298  } else {
299    inputArg = args[1] as StreamInput<K>;
300    options = args[2] as StreamOptions<K> | undefined;
301  }
302
303  const [chunks, setChunks] = useState<StreamOutput<K>[]>([]);
304  const [error, setError] = useState<RpcError | undefined>();
305  const [isStreaming, setIsStreaming] = useState(false);
306  const [isDone, setIsDone] = useState(false);
307  const controllerRef = useRef<AbortController | undefined>();
308  const optionsRef = useRef(options);
309  optionsRef.current = options;
310
311  const stop = useCallback(() => {
312    if (controllerRef.current) {
313      controllerRef.current.abort();
314      controllerRef.current = undefined;
315    }
316  }, []);
317
318  const start = useCallback(() => {
319    stop();
320    const controller = new AbortController();
321    controllerRef.current = controller;
322    setChunks([]);
323    setError(undefined);
324    setIsStreaming(true);
325    setIsDone(false);
326
327    (async () => {
328      try {
329        const callArgs: unknown[] = [key];
330        if (inputArg !== undefined) callArgs.push(inputArg);
331        const mergedCallOptions = { ...optionsRef.current?.callOptions, signal: controller.signal };
332        callArgs.push(mergedCallOptions);
333        const gen = (client.stream as (...a: unknown[]) => AsyncGenerator<unknown>)(...callArgs);
334        for await (const chunk of gen) {
335          if (controller.signal.aborted) break;
336          setChunks(prev => [...prev, chunk as StreamOutput<K>]);
337          optionsRef.current?.onChunk?.(chunk as StreamOutput<K>);
338        }
339        if (!controller.signal.aborted) {
340          setIsDone(true);
341          optionsRef.current?.onDone?.();
342        }
343      } catch (e) {
344        if (!controller.signal.aborted) {
345          setError(e as RpcError);
346          optionsRef.current?.onError?.(e as RpcError);
347        }
348      } finally {
349        setIsStreaming(false);
350      }
351    })();
352  }, [client, key, inputArg, stop]);
353
354  useEffect(() => {
355    return () => stop();
356  }, [stop]);
357
358  return { chunks, error, isStreaming, isDone, start, stop };
359}"#;
360
361const FRAMEWORK_IMPORT: &str =
362    "import { useState, useEffect, useRef, useCallback } from \"react\";";
363
364/// Generates the complete React reactive wrapper file content from a manifest.
365///
366/// Returns an empty string when the manifest contains no procedures (the caller
367/// should skip writing the file in that case).
368pub fn generate_react_file(
369    manifest: &Manifest,
370    client_import_path: &str,
371    types_import_path: &str,
372    preserve_docs: bool,
373) -> String {
374    common::generate_framework_file(
375        manifest,
376        client_import_path,
377        types_import_path,
378        preserve_docs,
379        &FrameworkConfig {
380            framework_import: Some(FRAMEWORK_IMPORT),
381            query_fn_name: "useQuery",
382            stream_fn_name: "useStream",
383            input_as_getter: false,
384            query_interfaces: &[QUERY_OPTIONS_INTERFACE, QUERY_RESULT_INTERFACE],
385            mutation_interfaces: &[MUTATION_OPTIONS_INTERFACE, MUTATION_RESULT_INTERFACE],
386            stream_interfaces: &[STREAM_OPTIONS_INTERFACE, STREAM_RESULT_INTERFACE],
387            query_impl: USE_QUERY_IMPL,
388            mutation_impl: USE_MUTATION_IMPL,
389            stream_impl: USE_STREAM_IMPL,
390        },
391    )
392}