From 8e55a11b1f7fce0264f78e88857ff6f7a07e0351 Mon Sep 17 00:00:00 2001 From: jaco-brink Date: Thu, 5 Feb 2026 01:24:12 +0000 Subject: [PATCH 1/3] feat: add video upload hook --- ...omizeCreateMuxVideoUploadByFileMutation.ts | 22 ++ .../TemplateCustomizeGetMyMuxVideoQuery.ts | 24 ++ .../utils/useVideoUpload/index.ts | 2 + .../useVideoUpload/useVideoUpload.spec.tsx | 373 ++++++++++++++++++ .../utils/useVideoUpload/useVideoUpload.tsx | 197 +++++++++ 5 files changed, 618 insertions(+) create mode 100644 apps/journeys-admin/__generated__/TemplateCustomizeCreateMuxVideoUploadByFileMutation.ts create mode 100644 apps/journeys-admin/__generated__/TemplateCustomizeGetMyMuxVideoQuery.ts create mode 100644 apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/index.ts create mode 100644 apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.spec.tsx create mode 100644 apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.tsx diff --git a/apps/journeys-admin/__generated__/TemplateCustomizeCreateMuxVideoUploadByFileMutation.ts b/apps/journeys-admin/__generated__/TemplateCustomizeCreateMuxVideoUploadByFileMutation.ts new file mode 100644 index 00000000000..879985d8772 --- /dev/null +++ b/apps/journeys-admin/__generated__/TemplateCustomizeCreateMuxVideoUploadByFileMutation.ts @@ -0,0 +1,22 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL mutation operation: TemplateCustomizeCreateMuxVideoUploadByFileMutation +// ==================================================== + +export interface TemplateCustomizeCreateMuxVideoUploadByFileMutation_createMuxVideoUploadByFile { + __typename: "MuxVideo"; + uploadUrl: string | null; + id: string; +} + +export interface TemplateCustomizeCreateMuxVideoUploadByFileMutation { + createMuxVideoUploadByFile: TemplateCustomizeCreateMuxVideoUploadByFileMutation_createMuxVideoUploadByFile; +} + +export interface TemplateCustomizeCreateMuxVideoUploadByFileMutationVariables { + name: string; +} diff --git a/apps/journeys-admin/__generated__/TemplateCustomizeGetMyMuxVideoQuery.ts b/apps/journeys-admin/__generated__/TemplateCustomizeGetMyMuxVideoQuery.ts new file mode 100644 index 00000000000..0f392894465 --- /dev/null +++ b/apps/journeys-admin/__generated__/TemplateCustomizeGetMyMuxVideoQuery.ts @@ -0,0 +1,24 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL query operation: TemplateCustomizeGetMyMuxVideoQuery +// ==================================================== + +export interface TemplateCustomizeGetMyMuxVideoQuery_getMyMuxVideo { + __typename: "MuxVideo"; + id: string; + assetId: string | null; + playbackId: string | null; + readyToStream: boolean; +} + +export interface TemplateCustomizeGetMyMuxVideoQuery { + getMyMuxVideo: TemplateCustomizeGetMyMuxVideoQuery_getMyMuxVideo; +} + +export interface TemplateCustomizeGetMyMuxVideoQueryVariables { + id: string; +} diff --git a/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/index.ts b/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/index.ts new file mode 100644 index 00000000000..c7b781c6ef0 --- /dev/null +++ b/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/index.ts @@ -0,0 +1,2 @@ +export { useVideoUpload } from './useVideoUpload' +export type { VideoUploadStatus } from './useVideoUpload' diff --git a/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.spec.tsx b/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.spec.tsx new file mode 100644 index 00000000000..0448750a093 --- /dev/null +++ b/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.spec.tsx @@ -0,0 +1,373 @@ +import { MockedProvider, MockedResponse } from '@apollo/client/testing' +import { act, renderHook, waitFor } from '@testing-library/react' +import { UpChunk } from '@mux/upchunk' +import { useDropzone } from 'react-dropzone' +import { GraphQLError } from 'graphql' +import { + useVideoUpload, + CREATE_MUX_VIDEO_UPLOAD_BY_FILE_MUTATION, + GET_MY_MUX_VIDEO_QUERY +} from './useVideoUpload' + +jest.mock('@mux/upchunk', () => ({ + UpChunk: { + createUpload: jest.fn() + } +})) + +jest.mock('react-dropzone', () => ({ + useDropzone: jest.fn(() => ({ + getRootProps: jest.fn(), + getInputProps: jest.fn(), + open: jest.fn() + })) +})) + +const createMuxVideoUploadByFileMock: MockedResponse = { + request: { + query: CREATE_MUX_VIDEO_UPLOAD_BY_FILE_MUTATION, + variables: { name: 'video.mp4' } + }, + result: { + data: { + createMuxVideoUploadByFile: { + __typename: 'MuxVideo', + uploadUrl: 'https://mux.com/upload', + id: 'videoId' + } + } + } +} + +const getMyMuxVideoMock: MockedResponse = { + request: { + query: GET_MY_MUX_VIDEO_QUERY, + variables: { id: 'videoId' } + }, + result: { + data: { + getMyMuxVideo: { + __typename: 'MuxVideo', + id: 'videoId', + assetId: 'assetId', + playbackId: 'playbackId', + readyToStream: true + } + } + } +} + +const getMyMuxVideoProcessingMock: MockedResponse = { + request: { + query: GET_MY_MUX_VIDEO_QUERY, + variables: { id: 'videoId' } + }, + result: { + data: { + getMyMuxVideo: { + __typename: 'MuxVideo', + id: 'videoId', + assetId: 'assetId', + playbackId: 'playbackId', + readyToStream: false + } + } + } +} + +describe('useVideoUpload', () => { + const file = new File([''], 'video.mp4', { type: 'video/mp4' }) + + it('should handle successful upload and polling', async () => { + const onUploadComplete = jest.fn() + const mockUpload = { + on: jest.fn(), + abort: jest.fn() + } + ;(UpChunk.createUpload as jest.Mock).mockReturnValue(mockUpload) + + const { result } = renderHook(() => useVideoUpload({ onUploadComplete }), { + wrapper: ({ children }) => ( + + {children} + + ) + }) + + await act(async () => { + await result.current.handleUpload(file) + }) + + expect(result.current.status).toBe('uploading') + expect(UpChunk.createUpload).toHaveBeenCalledWith({ + endpoint: 'https://mux.com/upload', + file, + chunkSize: 5120 + }) + + // Simulate progress + const progressCallback = mockUpload.on.mock.calls.find( + (call) => call[0] === 'progress' + )[1] + act(() => { + progressCallback({ detail: 50 }) + }) + expect(result.current.progress).toBe(50) + + // Simulate success + const successCallback = mockUpload.on.mock.calls.find( + (call) => call[0] === 'success' + )[1] + await act(async () => { + successCallback() + }) + + await waitFor(() => expect(result.current.status).toBe('completed')) + expect(onUploadComplete).toHaveBeenCalledWith('videoId') + }) + + it('should set videoId when upload starts', async () => { + const mockUpload = { + on: jest.fn(), + abort: jest.fn() + } + ;(UpChunk.createUpload as jest.Mock).mockReturnValue(mockUpload) + + const { result } = renderHook(() => useVideoUpload(), { + wrapper: ({ children }) => ( + + {children} + + ) + }) + + await act(async () => { + await result.current.handleUpload(file) + }) + + expect(result.current.videoId).toBe('videoId') + }) + + it('should handle upload error', async () => { + const onUploadError = jest.fn() + const mockUpload = { + on: jest.fn(), + abort: jest.fn() + } + ;(UpChunk.createUpload as jest.Mock).mockReturnValue(mockUpload) + + const { result } = renderHook(() => useVideoUpload({ onUploadError }), { + wrapper: ({ children }) => ( + + {children} + + ) + }) + + await act(async () => { + await result.current.handleUpload(file) + }) + + const errorCallback = mockUpload.on.mock.calls.find( + (call) => call[0] === 'error' + )[1] + + act(() => { + errorCallback() + }) + + expect(result.current.status).toBe('error') + expect(result.current.error).toBe('Upload failed') + expect(onUploadError).toHaveBeenCalledWith('Upload failed') + }) + + it('should handle file too large error', async () => { + const largeFile = new File([''], 'large.mp4', { type: 'video/mp4' }) + Object.defineProperty(largeFile, 'size', { value: 2 * 1024 * 1024 * 1024 }) // 2GB + + const { result } = renderHook(() => useVideoUpload(), { + wrapper: ({ children }) => ( + + {children} + + ) + }) + + await act(async () => { + await result.current.handleUpload(largeFile) + }) + + expect(result.current.error).toBe('File is too large. Max size is 1GB.') + }) + + it('should cancel upload', async () => { + const mockUpload = { + on: jest.fn(), + abort: jest.fn() + } + ;(UpChunk.createUpload as jest.Mock).mockReturnValue(mockUpload) + + const { result } = renderHook(() => useVideoUpload(), { + wrapper: ({ children }) => ( + + {children} + + ) + }) + + await act(async () => { + await result.current.handleUpload(file) + }) + + expect(result.current.status).toBe('uploading') + + act(() => { + result.current.cancelUpload() + }) + + expect(mockUpload.abort).toHaveBeenCalled() + expect(result.current.status).toBe('idle') + expect(result.current.progress).toBe(0) + expect(result.current.error).toBeUndefined() + }) + + it('should poll until ready', async () => { + const onUploadComplete = jest.fn() + const mockUpload = { + on: jest.fn(), + abort: jest.fn() + } + ;(UpChunk.createUpload as jest.Mock).mockReturnValue(mockUpload) + + const { result } = renderHook(() => useVideoUpload({ onUploadComplete }), { + wrapper: ({ children }) => ( + + {children} + + ) + }) + + await act(async () => { + await result.current.handleUpload(file) + }) + + // Simulate success to start polling + const successCallback = mockUpload.on.mock.calls.find( + (call) => call[0] === 'success' + )[1] + + await act(async () => { + successCallback() + }) + + // Wait for the immediate poll to be called and for status to change to processing + await waitFor(() => expect(result.current.status).toBe('processing')) + + // Wait for the second poll to complete and status to change to completed + // We use a longer timeout since we are using real timers and the interval is 5s + await waitFor(() => expect(result.current.status).toBe('completed'), { + timeout: 10000 + }) + expect(onUploadComplete).toHaveBeenCalledWith('videoId') + }, 15000) + + it('should handle polling error', async () => { + const onUploadError = jest.fn() + const mockUpload = { + on: jest.fn(), + abort: jest.fn() + } + ;(UpChunk.createUpload as jest.Mock).mockReturnValue(mockUpload) + + const pollingErrorMock: MockedResponse = { + request: { + query: GET_MY_MUX_VIDEO_QUERY, + variables: { id: 'videoId' } + }, + error: new Error('Polling failed') + } + + const { result } = renderHook(() => useVideoUpload({ onUploadError }), { + wrapper: ({ children }) => ( + + {children} + + ) + }) + + await act(async () => { + await result.current.handleUpload(file) + }) + + // Simulate success to start polling + const successCallback = mockUpload.on.mock.calls.find( + (call) => call[0] === 'success' + )[1] + + await act(async () => { + successCallback() + }) + + await waitFor(() => expect(result.current.status).toBe('error'), { + timeout: 15000 + }) + expect(result.current.error).toBe('Failed to check video status') + expect(onUploadError).toHaveBeenCalledWith('Failed to check video status') + }, 20000) + + it('should cleanup on unmount', async () => { + const mockUpload = { + on: jest.fn(), + abort: jest.fn() + } + ;(UpChunk.createUpload as jest.Mock).mockReturnValue(mockUpload) + + const { result, unmount } = renderHook(() => useVideoUpload(), { + wrapper: ({ children }) => ( + + {children} + + ) + }) + + await act(async () => { + await result.current.handleUpload(file) + }) + + unmount() + + expect(mockUpload.abort).toHaveBeenCalled() + }) +}) diff --git a/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.tsx b/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.tsx new file mode 100644 index 00000000000..bafbd93616e --- /dev/null +++ b/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.tsx @@ -0,0 +1,197 @@ +import { gql, useLazyQuery, useMutation } from '@apollo/client' +import { UpChunk } from '@mux/upchunk' +import { useCallback, useEffect, useRef, useState } from 'react' +import { useDropzone } from 'react-dropzone' + +export const CREATE_MUX_VIDEO_UPLOAD_BY_FILE_MUTATION = gql` + mutation TemplateCustomizeCreateMuxVideoUploadByFileMutation($name: String!) { + createMuxVideoUploadByFile(name: $name) { + uploadUrl + id + } + } +` + +export const GET_MY_MUX_VIDEO_QUERY = gql` + query TemplateCustomizeGetMyMuxVideoQuery($id: ID!) { + getMyMuxVideo(id: $id) { + id + assetId + playbackId + readyToStream + } + } +` + +const POLL_INTERVAL = 5000 // 5 seconds +const MAX_VIDEO_SIZE = 1073741824 // 1GB + +export type VideoUploadStatus = + | 'idle' + | 'uploading' + | 'processing' + | 'completed' + | 'error' + +interface UseVideoUploadOptions { + onUploadComplete?: (videoId: string) => void + onUploadError?: (error: string) => void +} + +export function useVideoUpload({ + onUploadComplete, + onUploadError +}: UseVideoUploadOptions = {}) { + const [status, setStatus] = useState('idle') + const [progress, setProgress] = useState(0) + const [error, setError] = useState() + const [videoId, setVideoId] = useState() + + const uploadInstanceRef = useRef<{ abort: () => void } | null>(null) + const pollingIntervalRef = useRef(null) + + const [createMuxVideoUploadByFile] = useMutation( + CREATE_MUX_VIDEO_UPLOAD_BY_FILE_MUTATION + ) + + const [getMyMuxVideo] = useLazyQuery(GET_MY_MUX_VIDEO_QUERY, { + fetchPolicy: 'network-only' + }) + + const clearPolling = useCallback(() => { + if (pollingIntervalRef.current != null) { + clearInterval(pollingIntervalRef.current) + pollingIntervalRef.current = null + } + }, []) + + const cancelUpload = useCallback(() => { + uploadInstanceRef.current?.abort() + uploadInstanceRef.current = null + clearPolling() + setStatus('idle') + setProgress(0) + setError(undefined) + }, [clearPolling]) + + const startPolling = useCallback( + (videoId: string) => { + setStatus('processing') + + const poll = async () => { + try { + const result = await getMyMuxVideo({ + variables: { id: videoId } + }) + + if (result.error != null) { + throw result.error + } + + if (result.data?.getMyMuxVideo?.readyToStream === true) { + clearPolling() + setStatus('completed') + onUploadComplete?.(videoId) + } + } catch (err) { + clearPolling() + setStatus('error') + setError('Failed to check video status') + onUploadError?.('Failed to check video status') + } + } + + void poll() + pollingIntervalRef.current = setInterval(poll, POLL_INTERVAL) + }, + [clearPolling, getMyMuxVideo, onUploadComplete, onUploadError] + ) + + const handleUpload = useCallback( + async (file: File) => { + if (file.size > MAX_VIDEO_SIZE) { + setError('File is too large. Max size is 1GB.') + return + } + + setStatus('uploading') + setProgress(0) + setError(undefined) + + try { + const { data } = await createMuxVideoUploadByFile({ + variables: { name: file.name } + }) + + const uploadUrl = data?.createMuxVideoUploadByFile?.uploadUrl + const videoId = data?.createMuxVideoUploadByFile?.id + setVideoId(videoId) + + if (uploadUrl == null || videoId == null) { + throw new Error('Failed to create upload URL') + } + + const upload = UpChunk.createUpload({ + endpoint: uploadUrl, + file, + chunkSize: 5120 // 5MB + }) + + uploadInstanceRef.current = upload + + upload.on('progress', (progress) => { + setProgress(progress.detail) + }) + + upload.on('success', () => { + uploadInstanceRef.current = null + startPolling(videoId) + }) + + upload.on('error', () => { + setStatus('error') + setError('Upload failed') + onUploadError?.('Upload failed') + }) + } catch (err) { + setStatus('error') + const message = err instanceof Error ? err.message : 'Upload failed' + setError(message) + onUploadError?.(message) + } + }, + [createMuxVideoUploadByFile, startPolling, onUploadError] + ) + + const { getRootProps, getInputProps, open } = useDropzone({ + onDropAccepted: (files) => { + void handleUpload(files[0]) + }, + noDrag: true, + multiple: false, + accept: { 'video/*': [] }, + disabled: status !== 'idle' && status !== 'error' && status !== 'completed' + }) + + // Cleanup on unmount + useEffect(() => { + return () => { + uploadInstanceRef.current?.abort() + if (pollingIntervalRef.current != null) { + clearInterval(pollingIntervalRef.current) + } + } + }, []) + + return { + handleUpload, + cancelUpload, + status, + progress, + error, + videoId, + open, + getInputProps, + getRootProps + } +} From 4d516331068abe2cc4cee5ec109d59728a9eae93 Mon Sep 17 00:00:00 2001 From: jaco-brink Date: Thu, 5 Feb 2026 03:00:35 +0000 Subject: [PATCH 2/3] feat: add exponential backoff and retry logic for video upload status polling --- .../useVideoUpload/useVideoUpload.spec.tsx | 84 +++++++++++-------- .../utils/useVideoUpload/useVideoUpload.tsx | 59 ++++++++++--- 2 files changed, 94 insertions(+), 49 deletions(-) diff --git a/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.spec.tsx b/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.spec.tsx index 0448750a093..c64c3a01adb 100644 --- a/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.spec.tsx +++ b/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.spec.tsx @@ -243,7 +243,7 @@ describe('useVideoUpload', () => { expect(result.current.error).toBeUndefined() }) - it('should poll until ready', async () => { + it('should poll with exponential backoff until ready', async () => { const onUploadComplete = jest.fn() const mockUpload = { on: jest.fn(), @@ -251,20 +251,23 @@ describe('useVideoUpload', () => { } ;(UpChunk.createUpload as jest.Mock).mockReturnValue(mockUpload) - const { result } = renderHook(() => useVideoUpload({ onUploadComplete }), { - wrapper: ({ children }) => ( - - {children} - - ) - }) + const { result } = renderHook( + () => useVideoUpload({ onUploadComplete, initialPollInterval: 100 }), + { + wrapper: ({ children }) => ( + + {children} + + ) + } + ) await act(async () => { await result.current.handleUpload(file) @@ -283,12 +286,14 @@ describe('useVideoUpload', () => { await waitFor(() => expect(result.current.status).toBe('processing')) // Wait for the second poll to complete and status to change to completed - // We use a longer timeout since we are using real timers and the interval is 5s + // We use a shorter timeout since we reduced the interval to 100ms for faster testing. + // Note: The backoff timing here (100ms, 150ms, etc.) is reduced for test performance + // and does not match the real-world production intervals (2s, 3s, etc.). await waitFor(() => expect(result.current.status).toBe('completed'), { - timeout: 10000 + timeout: 2000 }) expect(onUploadComplete).toHaveBeenCalledWith('videoId') - }, 15000) + }) it('should handle polling error', async () => { const onUploadError = jest.fn() @@ -306,23 +311,25 @@ describe('useVideoUpload', () => { error: new Error('Polling failed') } - const { result } = renderHook(() => useVideoUpload({ onUploadError }), { - wrapper: ({ children }) => ( - - {children} - - ) - }) + const { result } = renderHook( + () => useVideoUpload({ onUploadError, initialPollInterval: 100 }), + { + wrapper: ({ children }) => ( + + {children} + + ) + } + ) await act(async () => { await result.current.handleUpload(file) @@ -337,12 +344,15 @@ describe('useVideoUpload', () => { successCallback() }) + // Wait for all 3 retries to exhaust (3 retries * 100ms delay = ~300ms + buffer) + // Note: The backoff timing here is reduced for test performance and does not match + // the real-world production intervals. await waitFor(() => expect(result.current.status).toBe('error'), { - timeout: 15000 + timeout: 2000 }) expect(result.current.error).toBe('Failed to check video status') expect(onUploadError).toHaveBeenCalledWith('Failed to check video status') - }, 20000) + }) it('should cleanup on unmount', async () => { const mockUpload = { diff --git a/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.tsx b/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.tsx index bafbd93616e..d38c12b43c9 100644 --- a/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.tsx +++ b/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.tsx @@ -23,7 +23,9 @@ export const GET_MY_MUX_VIDEO_QUERY = gql` } ` -const POLL_INTERVAL = 5000 // 5 seconds +const INITIAL_POLL_INTERVAL = 2000 // 2 seconds +const MAX_POLL_INTERVAL = 30000 // 30 seconds +const MAX_RETRIES = 3 const MAX_VIDEO_SIZE = 1073741824 // 1GB export type VideoUploadStatus = @@ -36,11 +38,17 @@ export type VideoUploadStatus = interface UseVideoUploadOptions { onUploadComplete?: (videoId: string) => void onUploadError?: (error: string) => void + /** @default 2000 */ + initialPollInterval?: number + /** @default 3 */ + maxRetries?: number } export function useVideoUpload({ onUploadComplete, - onUploadError + onUploadError, + initialPollInterval = INITIAL_POLL_INTERVAL, + maxRetries = MAX_RETRIES }: UseVideoUploadOptions = {}) { const [status, setStatus] = useState('idle') const [progress, setProgress] = useState(0) @@ -48,7 +56,8 @@ export function useVideoUpload({ const [videoId, setVideoId] = useState() const uploadInstanceRef = useRef<{ abort: () => void } | null>(null) - const pollingIntervalRef = useRef(null) + const pollingTimeoutRef = useRef(null) + const retryCountRef = useRef(0) const [createMuxVideoUploadByFile] = useMutation( CREATE_MUX_VIDEO_UPLOAD_BY_FILE_MUTATION @@ -59,10 +68,11 @@ export function useVideoUpload({ }) const clearPolling = useCallback(() => { - if (pollingIntervalRef.current != null) { - clearInterval(pollingIntervalRef.current) - pollingIntervalRef.current = null + if (pollingTimeoutRef.current != null) { + clearTimeout(pollingTimeoutRef.current) + pollingTimeoutRef.current = null } + retryCountRef.current = 0 }, []) const cancelUpload = useCallback(() => { @@ -78,7 +88,7 @@ export function useVideoUpload({ (videoId: string) => { setStatus('processing') - const poll = async () => { + const poll = async (delay: number) => { try { const result = await getMyMuxVideo({ variables: { id: videoId } @@ -92,8 +102,27 @@ export function useVideoUpload({ clearPolling() setStatus('completed') onUploadComplete?.(videoId) + return } + + // Reset retries on successful query (even if not ready) + retryCountRef.current = 0 + + // Schedule next poll with exponential backoff + const nextDelay = Math.min(delay * 1.5, MAX_POLL_INTERVAL) + pollingTimeoutRef.current = setTimeout(() => { + void poll(nextDelay) + }, delay) } catch (err) { + // Retry on recoverable errors (e.g., network issues or 500s) + if (retryCountRef.current < maxRetries) { + retryCountRef.current++ + pollingTimeoutRef.current = setTimeout(() => { + void poll(delay) + }, delay) + return + } + clearPolling() setStatus('error') setError('Failed to check video status') @@ -101,10 +130,16 @@ export function useVideoUpload({ } } - void poll() - pollingIntervalRef.current = setInterval(poll, POLL_INTERVAL) + void poll(initialPollInterval) }, - [clearPolling, getMyMuxVideo, onUploadComplete, onUploadError] + [ + clearPolling, + getMyMuxVideo, + onUploadComplete, + onUploadError, + initialPollInterval, + maxRetries + ] ) const handleUpload = useCallback( @@ -177,8 +212,8 @@ export function useVideoUpload({ useEffect(() => { return () => { uploadInstanceRef.current?.abort() - if (pollingIntervalRef.current != null) { - clearInterval(pollingIntervalRef.current) + if (pollingTimeoutRef.current != null) { + clearTimeout(pollingTimeoutRef.current) } } }, []) From 48a7d7eb9845749c2bed071fd7e97fdf7b0dabcd Mon Sep 17 00:00:00 2001 From: jaco-brink Date: Sun, 8 Feb 2026 20:19:15 +0000 Subject: [PATCH 3/3] feat: coderabbitai - reset videoid on cancel, improve file size error handle states --- .../utils/useVideoUpload/useVideoUpload.spec.tsx | 12 ++++++++---- .../utils/useVideoUpload/useVideoUpload.tsx | 6 +++++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.spec.tsx b/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.spec.tsx index c64c3a01adb..dbdf2637014 100644 --- a/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.spec.tsx +++ b/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.spec.tsx @@ -1,8 +1,6 @@ import { MockedProvider, MockedResponse } from '@apollo/client/testing' import { act, renderHook, waitFor } from '@testing-library/react' import { UpChunk } from '@mux/upchunk' -import { useDropzone } from 'react-dropzone' -import { GraphQLError } from 'graphql' import { useVideoUpload, CREATE_MUX_VIDEO_UPLOAD_BY_FILE_MUTATION, @@ -191,10 +189,11 @@ describe('useVideoUpload', () => { }) it('should handle file too large error', async () => { + const onUploadError = jest.fn() const largeFile = new File([''], 'large.mp4', { type: 'video/mp4' }) Object.defineProperty(largeFile, 'size', { value: 2 * 1024 * 1024 * 1024 }) // 2GB - const { result } = renderHook(() => useVideoUpload(), { + const { result } = renderHook(() => useVideoUpload({ onUploadError }), { wrapper: ({ children }) => ( {children} @@ -206,7 +205,11 @@ describe('useVideoUpload', () => { await result.current.handleUpload(largeFile) }) + expect(result.current.status).toBe('error') expect(result.current.error).toBe('File is too large. Max size is 1GB.') + expect(onUploadError).toHaveBeenCalledWith( + 'File is too large. Max size is 1GB.' + ) }) it('should cancel upload', async () => { @@ -241,6 +244,7 @@ describe('useVideoUpload', () => { expect(result.current.status).toBe('idle') expect(result.current.progress).toBe(0) expect(result.current.error).toBeUndefined() + expect(result.current.videoId).toBeUndefined() }) it('should poll with exponential backoff until ready', async () => { @@ -345,7 +349,7 @@ describe('useVideoUpload', () => { }) // Wait for all 3 retries to exhaust (3 retries * 100ms delay = ~300ms + buffer) - // Note: The backoff timing here is reduced for test performance and does not match + // Note: The backoff timing here is reduced for test performance and does not match // the real-world production intervals. await waitFor(() => expect(result.current.status).toBe('error'), { timeout: 2000 diff --git a/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.tsx b/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.tsx index d38c12b43c9..dc0feb54dbe 100644 --- a/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.tsx +++ b/apps/journeys-admin/src/components/TemplateCustomization/utils/useVideoUpload/useVideoUpload.tsx @@ -82,6 +82,7 @@ export function useVideoUpload({ setStatus('idle') setProgress(0) setError(undefined) + setVideoId(undefined) }, [clearPolling]) const startPolling = useCallback( @@ -145,7 +146,10 @@ export function useVideoUpload({ const handleUpload = useCallback( async (file: File) => { if (file.size > MAX_VIDEO_SIZE) { - setError('File is too large. Max size is 1GB.') + const message = 'File is too large. Max size is 1GB.' + setStatus('error') + setError(message) + onUploadError?.(message) return }