@umituz/react-native-ai-generation-content 1.72.5 → 1.72.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. package/package.json +1 -1
  2. package/src/domains/background/infrastructure/services/job-poller.service.ts +20 -0
  3. package/src/domains/background/presentation/hooks/use-pending-jobs.ts +6 -3
  4. package/src/domains/creations/presentation/hooks/useCreationPersistence.ts +10 -1
  5. package/src/domains/creations/presentation/hooks/useProcessingJobsPoller.ts +20 -6
  6. package/src/domains/generation/wizard/infrastructure/strategies/image-generation.strategy.ts +8 -5
  7. package/src/domains/generation/wizard/infrastructure/strategies/shared/photo-extraction.utils.ts +20 -2
  8. package/src/domains/generation/wizard/presentation/hooks/generationExecutor.ts +6 -2
  9. package/src/domains/generation/wizard/presentation/hooks/usePhotoUploadState.ts +21 -7
  10. package/src/domains/generation/wizard/presentation/hooks/useVideoQueueGeneration.ts +22 -4
  11. package/src/domains/generation/wizard/presentation/hooks/useWizardGeneration.ts +9 -1
  12. package/src/domains/generation/wizard/presentation/hooks/videoQueuePoller.ts +6 -3
  13. package/src/domains/image-to-video/presentation/hooks/imageToVideoStrategy.ts +7 -1
  14. package/src/domains/result-preview/presentation/hooks/useResultActions.ts +6 -1
  15. package/src/domains/text-to-video/presentation/hooks/textToVideoStrategy.ts +7 -1
  16. package/src/infrastructure/services/generation-orchestrator.service.ts +5 -0
  17. package/src/infrastructure/services/image-feature-executor.service.ts +6 -1
  18. package/src/presentation/hooks/generation/orchestrator.ts +19 -5
  19. package/src/presentation/hooks/generation-flow-navigation.ts +13 -2
  20. package/src/presentation/hooks/use-generation.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/react-native-ai-generation-content",
3
- "version": "1.72.5",
3
+ "version": "1.72.6",
4
4
  "description": "Provider-agnostic AI generation orchestration for React Native with result preview components",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -77,6 +77,26 @@ export async function pollJob<T = unknown>(
77
77
  signal,
78
78
  } = options;
79
79
 
80
+ // Validate requestId early
81
+ if (!requestId || typeof requestId !== "string" || requestId.trim() === "") {
82
+ return {
83
+ success: false,
84
+ error: new Error("Invalid requestId provided"),
85
+ attempts: 0,
86
+ elapsedMs: 0,
87
+ };
88
+ }
89
+
90
+ // Validate model early
91
+ if (!model || typeof model !== "string" || model.trim() === "") {
92
+ return {
93
+ success: false,
94
+ error: new Error("Invalid model provided"),
95
+ attempts: 0,
96
+ elapsedMs: 0,
97
+ };
98
+ }
99
+
80
100
  const pollingConfig = { ...DEFAULT_POLLING_CONFIG, ...config };
81
101
  const { maxAttempts, maxTotalTimeMs, maxConsecutiveErrors } = pollingConfig;
82
102
 
@@ -85,7 +85,8 @@ export function usePendingJobs<TInput = unknown, TResult = unknown>(
85
85
  onSuccess: (id: string) => {
86
86
  queryClient.setQueryData<BackgroundJob<TInput, TResult>[]>(
87
87
  queryKey,
88
- (old: BackgroundJob<TInput, TResult>[] | undefined) => old?.filter((job: BackgroundJob<TInput, TResult>) => job.id !== id) ?? [],
88
+ (old: BackgroundJob<TInput, TResult>[] | undefined) =>
89
+ old?.filter((job: BackgroundJob<TInput, TResult>) => job && job.id !== id) ?? [],
89
90
  );
90
91
  },
91
92
  });
@@ -95,7 +96,8 @@ export function usePendingJobs<TInput = unknown, TResult = unknown>(
95
96
  onSuccess: () => {
96
97
  queryClient.setQueryData<BackgroundJob<TInput, TResult>[]>(
97
98
  queryKey,
98
- (old: BackgroundJob<TInput, TResult>[] | undefined) => old?.filter((job: BackgroundJob<TInput, TResult>) => job.status !== "completed") ?? [],
99
+ (old: BackgroundJob<TInput, TResult>[] | undefined) =>
100
+ old?.filter((job: BackgroundJob<TInput, TResult>) => job && job.status !== "completed") ?? [],
99
101
  );
100
102
  },
101
103
  });
@@ -105,7 +107,8 @@ export function usePendingJobs<TInput = unknown, TResult = unknown>(
105
107
  onSuccess: () => {
106
108
  queryClient.setQueryData<BackgroundJob<TInput, TResult>[]>(
107
109
  queryKey,
108
- (old: BackgroundJob<TInput, TResult>[] | undefined) => old?.filter((job: BackgroundJob<TInput, TResult>) => job.status !== "failed") ?? [],
110
+ (old: BackgroundJob<TInput, TResult>[] | undefined) =>
111
+ old?.filter((job: BackgroundJob<TInput, TResult>) => job && job.status !== "failed") ?? [],
109
112
  );
110
113
  },
111
114
  });
@@ -3,6 +3,8 @@
3
3
  */
4
4
 
5
5
  import { useCallback, useMemo } from "react";
6
+
7
+ declare const __DEV__: boolean;
6
8
  import { useAuth } from "@umituz/react-native-auth";
7
9
  import { createCreationsRepository } from "../../infrastructure/adapters";
8
10
  import type { Creation } from "../../domain/entities/Creation";
@@ -68,7 +70,14 @@ export function useCreationPersistence(
68
70
  });
69
71
 
70
72
  if (creditCost && onCreditDeduct) {
71
- onCreditDeduct(creditCost).catch(() => {});
73
+ onCreditDeduct(creditCost).catch((error) => {
74
+ // Log credit deduction errors for debugging
75
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
76
+ console.error("[CreationPersistence] Credit deduction failed:", error);
77
+ }
78
+ // Don't throw - credit deduction failure shouldn't block completion
79
+ // But we should notify user or retry in production
80
+ });
72
81
  }
73
82
  },
74
83
  [userId, repository, creditCost, onCreditDeduct]
@@ -42,6 +42,14 @@ export function useProcessingJobsPoller(
42
42
  const pollingRef = useRef<Set<string>>(new Set());
43
43
  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
44
44
 
45
+ // Convert to IDs to prevent re-creating array on every render
46
+ const processingJobIds = useMemo(
47
+ () => creations
48
+ .filter((c) => c.status === CREATION_STATUS.PROCESSING && c.requestId && c.model)
49
+ .map((c) => c.id),
50
+ [creations],
51
+ );
52
+
45
53
  const processingJobs = useMemo(
46
54
  () => creations.filter(
47
55
  (c) => c.status === CREATION_STATUS.PROCESSING && c.requestId && c.model,
@@ -104,7 +112,7 @@ export function useProcessingJobsPoller(
104
112
  };
105
113
 
106
114
  useEffect(() => {
107
- if (!enabled || !userId || processingJobs.length === 0) {
115
+ if (!enabled || !userId || processingJobIds.length === 0) {
108
116
  if (intervalRef.current) {
109
117
  clearInterval(intervalRef.current);
110
118
  intervalRef.current = null;
@@ -112,13 +120,19 @@ export function useProcessingJobsPoller(
112
120
  return;
113
121
  }
114
122
 
123
+ // Get current jobs at poll time to avoid stale closures
124
+ const pollCurrentJobs = () => {
125
+ const currentJobs = creations.filter(
126
+ (c) => c.status === CREATION_STATUS.PROCESSING && c.requestId && c.model,
127
+ );
128
+ currentJobs.forEach((job) => pollJobRef.current?.(job));
129
+ };
130
+
115
131
  // Initial poll
116
- processingJobs.forEach((job) => pollJobRef.current?.(job));
132
+ pollCurrentJobs();
117
133
 
118
134
  // Set up interval polling
119
- intervalRef.current = setInterval(() => {
120
- processingJobs.forEach((job) => pollJobRef.current?.(job));
121
- }, DEFAULT_POLL_INTERVAL_MS);
135
+ intervalRef.current = setInterval(pollCurrentJobs, DEFAULT_POLL_INTERVAL_MS);
122
136
 
123
137
  return () => {
124
138
  // Clear polling set first to prevent new operations
@@ -130,7 +144,7 @@ export function useProcessingJobsPoller(
130
144
  intervalRef.current = null;
131
145
  }
132
146
  };
133
- }, [enabled, userId, processingJobs]);
147
+ }, [enabled, userId, processingJobIds, creations]);
134
148
 
135
149
  return {
136
150
  processingCount: processingJobs.length,
@@ -83,14 +83,17 @@ function applyStyleEnhancements(prompt: string, wizardData: Record<string, unkno
83
83
  export function createImageStrategy(options: CreateImageStrategyOptions): WizardStrategy {
84
84
  const { scenario, creditCost } = options;
85
85
 
86
+ // Validate model early - fail fast
87
+ if (!scenario.model) {
88
+ throw new Error("Model is required for image generation");
89
+ }
90
+
91
+ const model = scenario.model;
92
+
86
93
  return {
87
94
  execute: async (input: unknown) => {
88
95
  const imageInput = input as WizardImageInput;
89
- if (!scenario.model) {
90
- throw new Error("Model is required for image generation");
91
- }
92
-
93
- const result = await executeImageGeneration(imageInput, scenario.model);
96
+ const result = await executeImageGeneration(imageInput, model);
94
97
 
95
98
  if (!result.success || !result.imageUrl) {
96
99
  throw new Error(result.error || "Image generation failed");
@@ -55,13 +55,31 @@ export async function extractPhotosAsBase64(
55
55
  return [];
56
56
  }
57
57
 
58
- const photosBase64 = await Promise.all(photoUris.map((uri) => readFileAsBase64(uri)));
59
- const validPhotos = photosBase64.filter(Boolean) as string[];
58
+ // Use Promise.allSettled to handle individual failures gracefully
59
+ const results = await Promise.allSettled(
60
+ photoUris.map(async (uri, index) => {
61
+ try {
62
+ return await readFileAsBase64(uri);
63
+ } catch (error) {
64
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
65
+ console.error(`[PhotoExtraction] Failed to read photo ${index}:`, error);
66
+ }
67
+ return null;
68
+ }
69
+ })
70
+ );
71
+
72
+ // Extract successful results only
73
+ const validPhotos = results
74
+ .map((result) => (result.status === "fulfilled" ? result.value : null))
75
+ .filter((photo): photo is string => typeof photo === "string" && photo.length > 0);
60
76
 
61
77
  if (enableDebugLogs && typeof __DEV__ !== "undefined" && __DEV__) {
78
+ const failedCount = results.filter((r) => r.status === "rejected").length;
62
79
  console.log("[PhotoExtraction] Converted photos", {
63
80
  total: photoUris.length,
64
81
  valid: validPhotos.length,
82
+ failed: failedCount,
65
83
  sizes: validPhotos.map((p) => `${(p.length / 1024).toFixed(1)}KB`),
66
84
  });
67
85
  }
@@ -44,9 +44,13 @@ export const executeWizardGeneration = async (params: ExecuteGenerationParams):
44
44
  console.log("[WizardGeneration] GENERATING -", isVideoMode ? "VIDEO" : "PHOTO");
45
45
  }
46
46
 
47
- const typedInput = input as { prompt?: string };
47
+ // Safely extract prompt with type guard
48
+ const prompt = typeof input === "object" && input !== null && "prompt" in input && typeof input.prompt === "string"
49
+ ? input.prompt
50
+ : "";
51
+
48
52
  const generationFn = isVideoMode ? videoGenerationFn : photoGenerationFn;
49
- await generationFn(input, typedInput.prompt || "");
53
+ await generationFn(input, prompt);
50
54
 
51
55
  if (isMountedRef.current) {
52
56
  dispatch({ type: "COMPLETE" });
@@ -5,6 +5,8 @@
5
5
  */
6
6
 
7
7
  import { useState, useCallback, useEffect, useRef } from "react";
8
+
9
+ declare const __DEV__: boolean;
8
10
  import { useMedia, MediaQuality, MediaValidationError, MEDIA_CONSTANTS } from "@umituz/react-native-design-system";
9
11
  import type { UploadedImage } from "../../../../../presentation/hooks/generation/useAIGenerateState";
10
12
 
@@ -51,6 +53,15 @@ export const usePhotoUploadState = ({
51
53
  const { pickImage, isLoading } = useMedia();
52
54
  const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
53
55
 
56
+ // Use refs to avoid effect re-runs on callback changes
57
+ const onErrorRef = useRef(onError);
58
+ const translationsRef = useRef(translations);
59
+
60
+ useEffect(() => {
61
+ onErrorRef.current = onError;
62
+ translationsRef.current = translations;
63
+ }, [onError, translations]);
64
+
54
65
  const maxFileSizeMB = config?.maxFileSizeMB ?? MEDIA_CONSTANTS.MAX_IMAGE_SIZE_MB;
55
66
 
56
67
  useEffect(() => {
@@ -61,28 +72,31 @@ export const usePhotoUploadState = ({
61
72
  }, [stepId, initialImage]);
62
73
 
63
74
  useEffect(() => {
75
+ // Clear any existing timeout first
76
+ if (timeoutRef.current) {
77
+ clearTimeout(timeoutRef.current);
78
+ timeoutRef.current = undefined;
79
+ }
80
+
64
81
  if (isLoading) {
65
82
  timeoutRef.current = setTimeout(() => {
66
83
  if (typeof __DEV__ !== "undefined" && __DEV__) {
67
84
  console.warn("[usePhotoUploadState] Image picker timeout - possible stuck state");
68
85
  }
69
- onError?.({
70
- title: translations.error,
86
+ onErrorRef.current?.({
87
+ title: translationsRef.current.error,
71
88
  message: "Image selection is taking too long. Please try again.",
72
89
  });
73
90
  }, 30000);
74
- } else {
75
- if (timeoutRef.current) {
76
- clearTimeout(timeoutRef.current);
77
- }
78
91
  }
79
92
 
80
93
  return () => {
81
94
  if (timeoutRef.current) {
82
95
  clearTimeout(timeoutRef.current);
96
+ timeoutRef.current = undefined;
83
97
  }
84
98
  };
85
- }, [isLoading, onError, translations]);
99
+ }, [isLoading]);
86
100
 
87
101
  const clearImage = useCallback(() => {
88
102
  setImage(null);
@@ -3,6 +3,8 @@
3
3
  */
4
4
 
5
5
  import { useEffect, useRef, useCallback, useState } from "react";
6
+
7
+ declare const __DEV__: boolean;
6
8
  import { pollQueueStatus } from "./videoQueuePoller";
7
9
  import { DEFAULT_POLL_INTERVAL_MS } from "../../../../../infrastructure/constants/polling.constants";
8
10
  import type { GenerationUrls } from "./generation-result.utils";
@@ -50,7 +52,11 @@ export function useVideoQueueGeneration(props: UseVideoQueueGenerationProps): Us
50
52
  imageUrl: urls.imageUrl,
51
53
  videoUrl: urls.videoUrl,
52
54
  });
53
- } catch {}
55
+ } catch (error) {
56
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
57
+ console.error("[VideoQueue] Failed to update completion status:", error);
58
+ }
59
+ }
54
60
  }
55
61
  resetRefs();
56
62
  onSuccess?.(urls);
@@ -64,7 +70,11 @@ export function useVideoQueueGeneration(props: UseVideoQueueGenerationProps): Us
64
70
  if (creationId && userId) {
65
71
  try {
66
72
  await persistence.updateToFailed(userId, creationId, errorMsg);
67
- } catch {}
73
+ } catch (error) {
74
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
75
+ console.error("[VideoQueue] Failed to update error status:", error);
76
+ }
77
+ }
68
78
  }
69
79
  resetRefs();
70
80
  onError?.(errorMsg);
@@ -107,7 +117,11 @@ export function useVideoQueueGeneration(props: UseVideoQueueGenerationProps): Us
107
117
  prompt,
108
118
  });
109
119
  creationIdRef.current = creationId;
110
- } catch {}
120
+ } catch (error) {
121
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
122
+ console.error("[VideoQueue] Failed to save processing creation:", error);
123
+ }
124
+ }
111
125
  }
112
126
 
113
127
  const queueResult = await strategy.submitToQueue(input);
@@ -126,7 +140,11 @@ export function useVideoQueueGeneration(props: UseVideoQueueGenerationProps): Us
126
140
  if (creationId && userId && queueResult.requestId && queueResult.model) {
127
141
  try {
128
142
  await persistence.updateRequestId(userId, creationId, queueResult.requestId, queueResult.model);
129
- } catch {}
143
+ } catch (error) {
144
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
145
+ console.error("[VideoQueue] Failed to update request ID:", error);
146
+ }
147
+ }
130
148
  }
131
149
 
132
150
  pollingRef.current = setInterval(() => void pollStatus(), DEFAULT_POLL_INTERVAL_MS);
@@ -72,7 +72,8 @@ export const useWizardGeneration = (props: UseWizardGenerationProps): UseWizardG
72
72
  if (isGeneratingStep && state.status === "IDLE" && !isAlreadyGenerating) {
73
73
  dispatch({ type: "START_PREPARATION" });
74
74
 
75
- executeWizardGeneration({
75
+ // Execute generation and handle errors properly
76
+ void executeWizardGeneration({
76
77
  wizardData,
77
78
  scenario,
78
79
  isVideoMode,
@@ -81,6 +82,13 @@ export const useWizardGeneration = (props: UseWizardGenerationProps): UseWizardG
81
82
  onError,
82
83
  videoGenerationFn: videoGeneration.startGeneration,
83
84
  photoGenerationFn: photoGeneration.startGeneration,
85
+ }).catch((error) => {
86
+ // Catch any unhandled errors from executeWizardGeneration
87
+ if (isMountedRef.current) {
88
+ const errorMsg = error instanceof Error ? error.message : String(error);
89
+ dispatch({ type: "ERROR", error: errorMsg });
90
+ onError?.(errorMsg);
91
+ }
84
92
  });
85
93
  }
86
94
 
@@ -16,12 +16,15 @@ interface PollParams {
16
16
  export const pollQueueStatus = async (params: PollParams): Promise<void> => {
17
17
  const { requestId, model, isPollingRef, pollingRef, onComplete, onError } = params;
18
18
 
19
+ // Atomic check-and-set to prevent race condition
19
20
  if (isPollingRef.current) return;
21
+ isPollingRef.current = true;
20
22
 
21
23
  const provider = providerRegistry.getActiveProvider();
22
- if (!provider) return;
23
-
24
- isPollingRef.current = true;
24
+ if (!provider) {
25
+ isPollingRef.current = false;
26
+ return;
27
+ }
25
28
  try {
26
29
  const status = await provider.getJobStatus(model, requestId);
27
30
  if (__DEV__) console.log("[VideoQueueGeneration] Poll:", status.status);
@@ -1,4 +1,6 @@
1
1
  import { executeImageToVideo } from "../../infrastructure/services";
2
+
3
+ declare const __DEV__: boolean;
2
4
  import type { GenerationStrategy } from "../../../../presentation/hooks/generation";
3
5
  import type {
4
6
  ImageToVideoFeatureConfig,
@@ -41,7 +43,11 @@ export const createImageToVideoStrategy = (
41
43
  type: "image-to-video",
42
44
  imageUri: input.imageUrl,
43
45
  metadata: input.options as Record<string, unknown> | undefined,
44
- }).catch(() => {});
46
+ }).catch((error) => {
47
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
48
+ console.error("[ImageToVideo] onGenerationStart callback failed:", error);
49
+ }
50
+ });
45
51
 
46
52
  const result = await executeImageToVideo(
47
53
  { imageUri: input.imageUrl, userId, motionPrompt: input.prompt, options: input.options },
@@ -4,6 +4,8 @@
4
4
  */
5
5
 
6
6
  import { useState, useCallback } from "react";
7
+
8
+ declare const __DEV__: boolean;
7
9
  import * as MediaLibrary from "expo-media-library";
8
10
  import * as Sharing from "expo-sharing";
9
11
  import {
@@ -88,7 +90,10 @@ export const useResultActions = (options: UseResultActionsOptions = {}): UseResu
88
90
  onShareEnd?.(true);
89
91
  }
90
92
  } catch (error: unknown) {
91
- if (__DEV__) console.log("Share cancelled or failed:", error);
93
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
94
+ const errorMsg = error instanceof Error ? error.message : String(error);
95
+ console.log("[ResultActions] Share cancelled or failed:", errorMsg);
96
+ }
92
97
  onShareEnd?.(true);
93
98
  } finally {
94
99
  setIsSharing(false);
@@ -1,4 +1,6 @@
1
1
  import { executeTextToVideo } from "../../infrastructure/services";
2
+
3
+ declare const __DEV__: boolean;
2
4
  import type { GenerationStrategy } from "../../../../presentation/hooks/generation";
3
5
  import type {
4
6
  TextToVideoConfig,
@@ -40,7 +42,11 @@ export const createTextToVideoStrategy = (
40
42
  type: "text-to-video",
41
43
  prompt: input.prompt,
42
44
  metadata: input.options as Record<string, unknown> | undefined,
43
- }).catch(() => {});
45
+ }).catch((error) => {
46
+ if (typeof __DEV__ !== "undefined" && __DEV__) {
47
+ console.error("[TextToVideo] onGenerationStart callback failed:", error);
48
+ }
49
+ });
44
50
 
45
51
  const result = await executeTextToVideo(
46
52
  { prompt: input.prompt, userId, options: input.options },
@@ -86,6 +86,11 @@ class GenerationOrchestratorService {
86
86
  throw pollResult.error ?? new Error("Polling failed");
87
87
  }
88
88
 
89
+ // Validate result exists before type assertion
90
+ if (!pollResult.data) {
91
+ throw new Error("Polling succeeded but no data returned");
92
+ }
93
+
89
94
  const result = pollResult.data as T;
90
95
  const duration = Date.now() - startTime;
91
96
 
@@ -72,10 +72,15 @@ export async function executeImageFeature(
72
72
  };
73
73
  }
74
74
 
75
+ // Safely extract requestId with type guard
76
+ const requestId = typeof result === "object" && result !== null && "requestId" in result && typeof result.requestId === "string"
77
+ ? result.requestId
78
+ : undefined;
79
+
75
80
  return {
76
81
  success: true,
77
82
  imageUrl,
78
- requestId: (result as { requestId?: string })?.requestId,
83
+ requestId,
79
84
  };
80
85
  } catch (error) {
81
86
  const message = extractErrorMessage(error, "Processing failed", `Image:${featureType}`);
@@ -34,6 +34,7 @@ export const useGenerationOrchestrator = <TInput, TResult>(
34
34
  const isGeneratingRef = useRef(false);
35
35
  const isMountedRef = useRef(true);
36
36
  const abortControllerRef = useRef<AbortController | null>(null);
37
+ const cleanupTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
37
38
 
38
39
  const offlineStore = useOfflineStore();
39
40
  const { showError, showSuccess } = useAlert();
@@ -44,18 +45,31 @@ export const useGenerationOrchestrator = <TInput, TResult>(
44
45
  return () => {
45
46
  isMountedRef.current = false;
46
47
  abortControllerRef.current?.abort();
48
+ // Clear any pending lifecycle complete timeout
49
+ if (cleanupTimeoutRef.current) {
50
+ clearTimeout(cleanupTimeoutRef.current);
51
+ cleanupTimeoutRef.current = null;
52
+ }
47
53
  };
48
54
  }, []);
49
55
 
50
56
  const handleLifecycleComplete = useCallback(
51
57
  (status: "success" | "error", result?: TResult, error?: GenerationError) => {
52
58
  if (!lifecycle?.onComplete) return;
59
+
60
+ // Clear any existing timeout first
61
+ if (cleanupTimeoutRef.current) {
62
+ clearTimeout(cleanupTimeoutRef.current);
63
+ cleanupTimeoutRef.current = null;
64
+ }
65
+
53
66
  const delay = lifecycle.completeDelay ?? 500;
54
- const timeoutId = setTimeout(() => {
55
- if (isMountedRef.current) lifecycle.onComplete?.(status, result, error);
67
+ cleanupTimeoutRef.current = setTimeout(() => {
68
+ if (isMountedRef.current) {
69
+ lifecycle.onComplete?.(status, result, error);
70
+ }
71
+ cleanupTimeoutRef.current = null;
56
72
  }, delay);
57
- // Store timeout ID for cleanup (if component unmounts during delay)
58
- return () => clearTimeout(timeoutId);
59
73
  },
60
74
  [lifecycle],
61
75
  );
@@ -237,7 +251,7 @@ export const useGenerationOrchestrator = <TInput, TResult>(
237
251
  abortControllerRef.current = null;
238
252
  }
239
253
  },
240
- [offlineStore.isOnline, moderation, alertMessages, strategy, checkCredits, onCreditsExhausted, executeGeneration, showError, onError, handleLifecycleComplete],
254
+ [offlineStore, moderation, alertMessages, strategy, checkCredits, onCreditsExhausted, executeGeneration, showError, onError, handleLifecycleComplete],
241
255
  );
242
256
 
243
257
  const reset = useCallback(() => {
@@ -24,7 +24,11 @@ export function createGoNextHandler(
24
24
  onComplete?.(newState);
25
25
  } else {
26
26
  setState({ ...state, currentStepIndex: nextIndex });
27
- onStepChange?.(nextIndex, config.photoSteps[nextIndex]);
27
+ // Bounds check before accessing array
28
+ const nextStep = config.photoSteps[nextIndex];
29
+ if (nextStep) {
30
+ onStepChange?.(nextIndex, nextStep);
31
+ }
28
32
  }
29
33
  };
30
34
  }
@@ -40,8 +44,15 @@ export function createGoBackHandler(
40
44
  if (!canGoBack) return;
41
45
 
42
46
  const prevIndex = currentStepIndex - 1;
47
+ // Validate index is within bounds
48
+ if (prevIndex < 0) return;
49
+
43
50
  setState((prev) => ({ ...prev, currentStepIndex: prevIndex, isComplete: false }));
44
- onStepChange?.(prevIndex, config.photoSteps[prevIndex]);
51
+ // Bounds check before accessing array
52
+ const prevStep = config.photoSteps[prevIndex];
53
+ if (prevStep) {
54
+ onStepChange?.(prevIndex, prevStep);
55
+ }
45
56
  };
46
57
  }
47
58
 
@@ -88,7 +88,7 @@ export function useGeneration<T = unknown>(
88
88
 
89
89
  const genResult = await generationOrchestrator.generate<T>(request);
90
90
 
91
- if (abortRef.current || abortControllerRef.current.signal.aborted) return;
91
+ if (abortRef.current || abortControllerRef.current?.signal.aborted) return;
92
92
 
93
93
  setResult(genResult);
94
94