@shopify/shop-minis-react 0.0.33 → 0.0.35

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 (86) hide show
  1. package/dist/_virtual/index10.js +2 -2
  2. package/dist/_virtual/index2.js +4 -4
  3. package/dist/_virtual/index3.js +4 -4
  4. package/dist/_virtual/index8.js +2 -2
  5. package/dist/_virtual/index9.js +2 -2
  6. package/dist/components/atoms/alert-dialog.js.map +1 -1
  7. package/dist/components/atoms/icon-button.js +12 -12
  8. package/dist/components/atoms/icon-button.js.map +1 -1
  9. package/dist/components/atoms/image.js +52 -0
  10. package/dist/components/atoms/image.js.map +1 -0
  11. package/dist/components/atoms/text-input.js +22 -0
  12. package/dist/components/atoms/text-input.js.map +1 -0
  13. package/dist/components/commerce/merchant-card.js +2 -1
  14. package/dist/components/commerce/merchant-card.js.map +1 -1
  15. package/dist/components/commerce/product-card.js +11 -11
  16. package/dist/components/commerce/product-card.js.map +1 -1
  17. package/dist/components/content/image-content-wrapper.js +29 -22
  18. package/dist/components/content/image-content-wrapper.js.map +1 -1
  19. package/dist/components/ui/input.js +15 -9
  20. package/dist/components/ui/input.js.map +1 -1
  21. package/dist/hooks/content/useCreateImageContent.js +16 -22
  22. package/dist/hooks/content/useCreateImageContent.js.map +1 -1
  23. package/dist/hooks/storage/useImageUpload.js +36 -37
  24. package/dist/hooks/storage/useImageUpload.js.map +1 -1
  25. package/dist/hooks/util/useKeyboardAvoidingView.js +23 -0
  26. package/dist/hooks/util/useKeyboardAvoidingView.js.map +1 -0
  27. package/dist/index.js +218 -212
  28. package/dist/index.js.map +1 -1
  29. package/dist/mocks.js +4 -1
  30. package/dist/mocks.js.map +1 -1
  31. package/dist/shop-minis-platform/src/types/content.js.map +1 -1
  32. package/dist/shop-minis-react/node_modules/.pnpm/@xmldom_xmldom@0.8.10/node_modules/@xmldom/xmldom/lib/index.js +1 -1
  33. package/dist/shop-minis-react/node_modules/.pnpm/color-string@1.9.1/node_modules/color-string/index.js +1 -1
  34. package/dist/shop-minis-react/node_modules/.pnpm/use-sync-external-store@1.5.0_react@19.1.0/node_modules/use-sync-external-store/shim/index.js +1 -1
  35. package/dist/shop-minis-react/node_modules/.pnpm/video.js@8.23.3/node_modules/video.js/dist/video.es.js +1 -1
  36. package/dist/utils/colors.js +1 -1
  37. package/dist/utils/image.js +46 -9
  38. package/dist/utils/image.js.map +1 -1
  39. package/package.json +21 -4
  40. package/src/components/atoms/alert-dialog.test.tsx +67 -0
  41. package/src/components/atoms/alert-dialog.tsx +13 -11
  42. package/src/components/atoms/favorite-button.test.tsx +56 -0
  43. package/src/components/atoms/icon-button.tsx +1 -1
  44. package/src/components/atoms/image.test.tsx +108 -0
  45. package/src/components/atoms/{thumbhash-image.tsx → image.tsx} +14 -14
  46. package/src/components/atoms/product-variant-price.test.tsx +128 -0
  47. package/src/components/atoms/text-input.test.tsx +104 -0
  48. package/src/components/atoms/text-input.tsx +31 -0
  49. package/src/components/commerce/merchant-card.test.tsx +261 -0
  50. package/src/components/commerce/merchant-card.tsx +4 -2
  51. package/src/components/commerce/product-card.test.tsx +364 -0
  52. package/src/components/commerce/product-card.tsx +2 -2
  53. package/src/components/commerce/product-link.test.tsx +483 -0
  54. package/src/components/commerce/quantity-selector.test.tsx +382 -0
  55. package/src/components/commerce/search.test.tsx +487 -0
  56. package/src/components/content/image-content-wrapper.test.tsx +92 -0
  57. package/src/components/content/image-content-wrapper.tsx +9 -2
  58. package/src/components/index.ts +2 -1
  59. package/src/components/navigation/transition-link.test.tsx +155 -0
  60. package/src/components/ui/input.test.tsx +21 -0
  61. package/src/components/ui/input.tsx +10 -1
  62. package/src/hooks/content/useCreateImageContent.test.ts +352 -0
  63. package/src/hooks/content/useCreateImageContent.ts +1 -7
  64. package/src/hooks/index.ts +1 -0
  65. package/src/hooks/navigation/useNavigateWithTransition.test.ts +371 -0
  66. package/src/hooks/navigation/useViewTransitions.test.ts +469 -0
  67. package/src/hooks/product/useProductSearch.test.ts +470 -0
  68. package/src/hooks/storage/useAsyncStorage.test.ts +225 -0
  69. package/src/hooks/storage/useImageUpload.test.ts +322 -0
  70. package/src/hooks/storage/useImageUpload.ts +22 -20
  71. package/src/hooks/util/useKeyboardAvoidingView.ts +37 -0
  72. package/src/internal/useHandleAction.test.ts +265 -0
  73. package/src/internal/useShopActionsDataFetching.test.ts +465 -0
  74. package/src/mocks.ts +3 -1
  75. package/src/providers/ImagePickerProvider.test.tsx +467 -0
  76. package/src/stories/ProductCard.stories.tsx +2 -2
  77. package/src/stories/TextInput.stories.tsx +26 -0
  78. package/src/test-setup.ts +34 -0
  79. package/src/test-utils.tsx +167 -0
  80. package/src/utils/image.ts +73 -0
  81. package/src/utils/index.ts +1 -1
  82. package/dist/components/atoms/thumbhash-image.js +0 -54
  83. package/dist/components/atoms/thumbhash-image.js.map +0 -1
  84. package/dist/utils/imageToDataUri.js +0 -10
  85. package/dist/utils/imageToDataUri.js.map +0 -1
  86. package/src/utils/imageToDataUri.ts +0 -8
@@ -0,0 +1,322 @@
1
+ import {renderHook, act} from '@testing-library/react'
2
+ import {describe, expect, it, vi, beforeEach} from 'vitest'
3
+
4
+ import {useShopActions} from '../../internal/useShopActions'
5
+
6
+ import {useImageUpload} from './useImageUpload'
7
+
8
+ // Mock dependencies
9
+ vi.mock('../../internal/useShopActions', () => ({
10
+ useShopActions: vi.fn(() => ({
11
+ createImageUploadLink: vi.fn(),
12
+ completeImageUpload: vi.fn(),
13
+ })),
14
+ }))
15
+
16
+ vi.mock('../../utils', () => ({
17
+ fileToDataUri: vi.fn((file: File) =>
18
+ Promise.resolve(`data:${file.type};base64,mockbase64data`)
19
+ ),
20
+ }))
21
+
22
+ // Mock fetch globally
23
+ global.fetch = vi.fn()
24
+
25
+ describe('useImageUpload', () => {
26
+ let mockCreateImageUploadLink: ReturnType<typeof vi.fn>
27
+ let mockCompleteImageUpload: ReturnType<typeof vi.fn>
28
+
29
+ beforeEach(() => {
30
+ vi.clearAllMocks()
31
+
32
+ // Reset fetch mock with proper blob() implementation
33
+ ;(global.fetch as any).mockImplementation(async (url: string) => {
34
+ // For data URI fetch (fileToDataUri result)
35
+ if (url.startsWith('data:')) {
36
+ return {
37
+ blob: async () => new Blob(['test image data'], {type: 'image/jpeg'}),
38
+ }
39
+ }
40
+ // Default for other fetches
41
+ return {
42
+ ok: true,
43
+ text: async () => 'Upload successful',
44
+ }
45
+ })
46
+
47
+ // Set up mock actions with proper implementations
48
+ mockCreateImageUploadLink = vi.fn().mockResolvedValue({
49
+ ok: true,
50
+ data: {
51
+ targets: [
52
+ {
53
+ url: 'https://storage.googleapis.com/upload',
54
+ resourceUrl: 'https://storage.googleapis.com/resource/123',
55
+ parameters: [
56
+ {name: 'key', value: 'test-key'},
57
+ {name: 'policy', value: 'test-policy'},
58
+ ],
59
+ },
60
+ ],
61
+ },
62
+ })
63
+
64
+ mockCompleteImageUpload = vi.fn().mockResolvedValue({
65
+ ok: true,
66
+ data: {
67
+ files: [
68
+ {
69
+ id: 'uploaded-image-id',
70
+ fileStatus: 'READY',
71
+ image: {
72
+ url: 'https://example.com/image.jpg',
73
+ },
74
+ },
75
+ ],
76
+ },
77
+ })
78
+
79
+ // Update the mocks to return our mock actions
80
+ ;(useShopActions as any).mockReturnValue({
81
+ createImageUploadLink: mockCreateImageUploadLink,
82
+ completeImageUpload: mockCompleteImageUpload,
83
+ })
84
+ })
85
+
86
+ describe('uploadImage', () => {
87
+ it('successfully uploads an image', async () => {
88
+ const {result} = renderHook(() => useImageUpload())
89
+
90
+ const testFile = new File(['test image'], 'test.jpg', {
91
+ type: 'image/jpeg',
92
+ })
93
+
94
+ let uploadedImages: any
95
+ await act(async () => {
96
+ uploadedImages = await result.current.uploadImage(testFile)
97
+ })
98
+
99
+ // Verify the upload flow
100
+ expect(mockCreateImageUploadLink).toHaveBeenCalledWith({
101
+ input: [
102
+ {
103
+ mimeType: 'image/jpeg',
104
+ fileSize: 10, // 'test image'.length
105
+ },
106
+ ],
107
+ })
108
+
109
+ // Verify GCS upload
110
+ expect(global.fetch).toHaveBeenCalledWith(
111
+ 'https://storage.googleapis.com/upload',
112
+ expect.objectContaining({
113
+ method: 'POST',
114
+ body: expect.any(FormData),
115
+ })
116
+ )
117
+
118
+ expect(mockCompleteImageUpload).toHaveBeenCalledWith({
119
+ resourceUrls: ['https://storage.googleapis.com/resource/123'],
120
+ })
121
+
122
+ expect(uploadedImages).toEqual([
123
+ {
124
+ id: 'uploaded-image-id',
125
+ imageUrl: 'https://example.com/image.jpg',
126
+ resourceUrl: 'https://storage.googleapis.com/resource/123',
127
+ },
128
+ ])
129
+ })
130
+
131
+ it('throws error when createImageUploadLink fails', async () => {
132
+ mockCreateImageUploadLink.mockResolvedValue({
133
+ ok: false,
134
+ error: {
135
+ message: 'Failed to create upload link',
136
+ },
137
+ })
138
+
139
+ const {result} = renderHook(() => useImageUpload())
140
+
141
+ const testFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
142
+
143
+ await expect(
144
+ act(async () => {
145
+ await result.current.uploadImage(testFile)
146
+ })
147
+ ).rejects.toThrow('Failed to create upload link')
148
+ })
149
+
150
+ it('throws error when GCS upload fails', async () => {
151
+ // Mock failed fetch for GCS upload
152
+ ;(global.fetch as any).mockImplementation(async (url: string) => {
153
+ if (url.startsWith('data:')) {
154
+ return {
155
+ blob: async () => new Blob(['test'], {type: 'image/jpeg'}),
156
+ }
157
+ }
158
+ // GCS upload fails
159
+ return {
160
+ ok: false,
161
+ text: async () => 'Upload failed',
162
+ }
163
+ })
164
+
165
+ const {result} = renderHook(() => useImageUpload())
166
+
167
+ const testFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
168
+
169
+ await expect(
170
+ act(async () => {
171
+ await result.current.uploadImage(testFile)
172
+ })
173
+ ).rejects.toThrow('Failed to upload image')
174
+ })
175
+
176
+ it('throws error when completeImageUpload fails', async () => {
177
+ mockCompleteImageUpload.mockResolvedValue({
178
+ ok: false,
179
+ error: {
180
+ message: 'Failed to complete upload',
181
+ },
182
+ })
183
+
184
+ const {result} = renderHook(() => useImageUpload())
185
+
186
+ const testFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
187
+
188
+ await expect(
189
+ act(async () => {
190
+ await result.current.uploadImage(testFile)
191
+ })
192
+ ).rejects.toThrow('Failed to complete upload')
193
+ })
194
+
195
+ it('polls until image is ready', async () => {
196
+ // First two calls return PROCESSING, third returns READY
197
+ mockCompleteImageUpload
198
+ .mockResolvedValueOnce({
199
+ ok: true,
200
+ data: {
201
+ files: [
202
+ {
203
+ id: 'uploaded-image-id',
204
+ fileStatus: 'PROCESSING',
205
+ },
206
+ ],
207
+ },
208
+ })
209
+ .mockResolvedValueOnce({
210
+ ok: true,
211
+ data: {
212
+ files: [
213
+ {
214
+ id: 'uploaded-image-id',
215
+ fileStatus: 'PROCESSING',
216
+ },
217
+ ],
218
+ },
219
+ })
220
+ .mockResolvedValueOnce({
221
+ ok: true,
222
+ data: {
223
+ files: [
224
+ {
225
+ id: 'uploaded-image-id',
226
+ fileStatus: 'READY',
227
+ image: {
228
+ url: 'https://example.com/processed.jpg',
229
+ },
230
+ },
231
+ ],
232
+ },
233
+ })
234
+
235
+ // Speed up test by mocking setTimeout
236
+ vi.useFakeTimers()
237
+
238
+ const {result} = renderHook(() => useImageUpload())
239
+
240
+ const testFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
241
+
242
+ // Start the upload without awaiting
243
+ let uploadPromise: Promise<any>
244
+ await act(async () => {
245
+ uploadPromise = result.current.uploadImage(testFile)
246
+ })
247
+
248
+ // Advance timers for polling
249
+ await act(async () => {
250
+ await vi.advanceTimersByTimeAsync(2000)
251
+ })
252
+
253
+ const uploadedImages = await uploadPromise!
254
+
255
+ expect(mockCompleteImageUpload).toHaveBeenCalledTimes(3)
256
+ expect(uploadedImages).toEqual([
257
+ {
258
+ id: 'uploaded-image-id',
259
+ imageUrl: 'https://example.com/processed.jpg',
260
+ resourceUrl: 'https://storage.googleapis.com/resource/123',
261
+ },
262
+ ])
263
+
264
+ vi.useRealTimers()
265
+ })
266
+
267
+ it('handles file without initial size', async () => {
268
+ const {result} = renderHook(() => useImageUpload())
269
+
270
+ // Create a file without size property set
271
+ const testFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
272
+
273
+ let uploadedImages: any
274
+ await act(async () => {
275
+ uploadedImages = await result.current.uploadImage(testFile)
276
+ })
277
+
278
+ expect(uploadedImages).toEqual([
279
+ {
280
+ id: 'uploaded-image-id',
281
+ imageUrl: 'https://example.com/image.jpg',
282
+ resourceUrl: 'https://storage.googleapis.com/resource/123',
283
+ },
284
+ ])
285
+ })
286
+
287
+ it('includes all form data parameters in GCS upload', async () => {
288
+ let capturedFormData: FormData | undefined
289
+ ;(global.fetch as any).mockImplementation(
290
+ async (url: string, options: any) => {
291
+ if (url.startsWith('data:')) {
292
+ return {
293
+ blob: async () => new Blob(['test'], {type: 'image/jpeg'}),
294
+ }
295
+ }
296
+ // eslint-disable-next-line jest/no-if
297
+ if (url === 'https://storage.googleapis.com/upload') {
298
+ capturedFormData = options.body
299
+ }
300
+ return {
301
+ ok: true,
302
+ text: async () => 'Upload successful',
303
+ }
304
+ }
305
+ )
306
+
307
+ const {result} = renderHook(() => useImageUpload())
308
+
309
+ const testFile = new File(['test'], 'test.jpg', {type: 'image/jpeg'})
310
+
311
+ await act(async () => {
312
+ await result.current.uploadImage(testFile)
313
+ })
314
+
315
+ // Verify FormData contains all expected fields
316
+ expect(capturedFormData).toBeDefined()
317
+ expect(capturedFormData?.get('key')).toBe('test-key')
318
+ expect(capturedFormData?.get('policy')).toBe('test-policy')
319
+ expect(capturedFormData?.get('file')).toBeDefined()
320
+ })
321
+ })
322
+ })
@@ -1,10 +1,18 @@
1
1
  import {useCallback} from 'react'
2
2
 
3
3
  import {useShopActions} from '../../internal/useShopActions'
4
+ import {fileToDataUri} from '../../utils'
4
5
 
5
6
  import type {UploadTarget} from '@shopify/shop-minis-platform/actions'
6
7
 
7
8
  export interface UploadImageParams {
9
+ /**
10
+ * The file to upload.
11
+ */
12
+ image: File
13
+ }
14
+
15
+ interface ProcessedImage {
8
16
  /**
9
17
  * The MIME type of the image.
10
18
  */
@@ -12,11 +20,11 @@ export interface UploadImageParams {
12
20
  /**
13
21
  * The size of the image in bytes.
14
22
  */
15
- fileSize?: number
23
+ fileSize: number
16
24
  /**
17
- * The URI of the image to upload.
25
+ * The file blob of the image.
18
26
  */
19
- uri: string
27
+ fileBlob: Blob
20
28
  }
21
29
 
22
30
  export interface UploadedImage {
@@ -36,28 +44,27 @@ export interface UploadedImage {
36
44
 
37
45
  interface UseImageUploadReturns {
38
46
  /**
39
- * Upload an image attached to the current user.
47
+ * Upload an image which will be attached to the current user.
40
48
  */
41
- uploadImage: (params: UploadImageParams[]) => Promise<UploadedImage[]>
49
+ uploadImage: (image: File) => Promise<UploadedImage[]>
42
50
  }
43
51
 
44
52
  // Fetch file data and detect file sizes if not provided
45
53
  // Works with file://, data:, and http(s):// URIs
46
- const processFileData = async (image: UploadImageParams) => {
47
- const response = await fetch(image.uri)
54
+ const processFileData = async (image: File): Promise<ProcessedImage> => {
55
+ const uri = await fileToDataUri(image)
56
+
57
+ const response = await fetch(uri)
48
58
  const blob = await response.blob()
49
59
 
50
60
  return {
51
- ...image,
52
- fileSize: image.fileSize ?? blob.size,
61
+ mimeType: image.type,
62
+ fileSize: image.size ?? blob.size,
53
63
  fileBlob: blob,
54
64
  }
55
65
  }
56
66
 
57
- const uploadFileToGCS = async (
58
- image: UploadImageParams & {fileSize: number; fileBlob: Blob},
59
- target: UploadTarget
60
- ) => {
67
+ const uploadFileToGCS = async (image: ProcessedImage, target: UploadTarget) => {
61
68
  const formData = new FormData()
62
69
  target.parameters.forEach(({name, value}: {name: string; value: string}) => {
63
70
  formData.append(name, value)
@@ -84,13 +91,8 @@ export const useImageUpload = (): UseImageUploadReturns => {
84
91
  const {createImageUploadLink, completeImageUpload} = useShopActions()
85
92
 
86
93
  const uploadImage = useCallback(
87
- async (params: UploadImageParams[]) => {
88
- if (params.length > 1) {
89
- throw new Error('Multiple image upload is not supported yet')
90
- }
91
-
92
- const imageParams = params[0]
93
- const processedImageParams = await processFileData(imageParams)
94
+ async (image: File) => {
95
+ const processedImageParams = await processFileData(image)
94
96
 
95
97
  const links = await createImageUploadLink({
96
98
  input: [
@@ -0,0 +1,37 @@
1
+ import {RefObject, useCallback} from 'react'
2
+
3
+ import {useShopActions} from '../../internal/useShopActions'
4
+
5
+ export interface UseKeyboardAvoidingViewReturns {
6
+ /**
7
+ * function to call when the input is focused
8
+ */
9
+ onFocus: (ref: RefObject<HTMLElement | null>) => void
10
+ /**
11
+ * function to call when the input is blurred
12
+ */
13
+ onBlur: () => void
14
+ }
15
+
16
+ export const useKeyboardAvoidingView = (): UseKeyboardAvoidingViewReturns => {
17
+ const {translateContentUp, translateContentDown} = useShopActions()
18
+
19
+ const onFocus = useCallback(
20
+ (ref: RefObject<HTMLElement | null>) => {
21
+ if (ref.current) {
22
+ const rect = ref.current.getBoundingClientRect()
23
+ translateContentUp({inputYPosition: rect.bottom})
24
+ }
25
+ },
26
+ [translateContentUp]
27
+ )
28
+
29
+ const onBlur = useCallback(() => {
30
+ translateContentDown()
31
+ }, [translateContentDown])
32
+
33
+ return {
34
+ onFocus,
35
+ onBlur,
36
+ }
37
+ }
@@ -0,0 +1,265 @@
1
+ import {renderHook} from '@testing-library/react'
2
+ import {describe, expect, it, vi} from 'vitest'
3
+
4
+ import {useHandleAction} from './useHandleAction'
5
+
6
+ import type {ShopActionResult} from '@shopify/shop-minis-platform/actions'
7
+
8
+ describe('useHandleAction', () => {
9
+ describe('Success Case', () => {
10
+ it('returns data when action succeeds', async () => {
11
+ const mockData = {id: '123', name: 'Test'}
12
+ const mockAction = vi.fn(() =>
13
+ Promise.resolve({
14
+ ok: true as const,
15
+ data: mockData,
16
+ } as ShopActionResult<typeof mockData>)
17
+ )
18
+
19
+ const {result} = renderHook(() => useHandleAction(mockAction))
20
+
21
+ const data = await result.current()
22
+
23
+ expect(data).toEqual(mockData)
24
+ expect(mockAction).toHaveBeenCalledTimes(1)
25
+ })
26
+
27
+ it('passes arguments to the action correctly', async () => {
28
+ const mockData = {success: true}
29
+ const mockAction = vi.fn((_arg1: string, _arg2: number) =>
30
+ Promise.resolve({
31
+ ok: true as const,
32
+ data: mockData,
33
+ } as ShopActionResult<typeof mockData>)
34
+ )
35
+
36
+ const {result} = renderHook(() => useHandleAction(mockAction))
37
+
38
+ await result.current('test', 42)
39
+
40
+ expect(mockAction).toHaveBeenCalledWith('test', 42)
41
+ })
42
+
43
+ it('handles complex data structures', async () => {
44
+ const complexData = {
45
+ user: {
46
+ id: '1',
47
+ name: 'John',
48
+ addresses: [
49
+ {street: '123 Main St', city: 'New York'},
50
+ {street: '456 Oak Ave', city: 'Boston'},
51
+ ],
52
+ },
53
+ metadata: {
54
+ timestamp: '2024-01-01',
55
+ version: 2,
56
+ },
57
+ }
58
+
59
+ const mockAction = vi.fn(() =>
60
+ Promise.resolve({
61
+ ok: true as const,
62
+ data: complexData,
63
+ } as ShopActionResult<typeof complexData>)
64
+ )
65
+
66
+ const {result} = renderHook(() => useHandleAction(mockAction))
67
+
68
+ const data = await result.current()
69
+
70
+ expect(data).toEqual(complexData)
71
+ })
72
+
73
+ it('handles null/undefined data', async () => {
74
+ const mockAction = vi.fn(() =>
75
+ Promise.resolve({
76
+ ok: true as const,
77
+ data: null,
78
+ } as ShopActionResult<null>)
79
+ )
80
+
81
+ const {result} = renderHook(() => useHandleAction(mockAction))
82
+
83
+ const data = await result.current()
84
+
85
+ expect(data).toBeNull()
86
+ })
87
+ })
88
+
89
+ describe('Error Case', () => {
90
+ it('throws error when action fails', async () => {
91
+ const mockError = {
92
+ code: 'ERROR_CODE',
93
+ message: 'Something went wrong',
94
+ }
95
+
96
+ const mockAction = vi.fn(() =>
97
+ Promise.resolve({
98
+ ok: false as const,
99
+ error: mockError,
100
+ } as ShopActionResult<any>)
101
+ )
102
+
103
+ const {result} = renderHook(() => useHandleAction(mockAction))
104
+
105
+ await expect(result.current()).rejects.toEqual(mockError)
106
+ expect(mockAction).toHaveBeenCalledTimes(1)
107
+ })
108
+
109
+ it('preserves error structure', async () => {
110
+ const complexError = {
111
+ code: 'VALIDATION_ERROR',
112
+ message: 'Validation failed',
113
+ details: {
114
+ fields: ['email', 'password'],
115
+ reasons: ['Invalid format', 'Too short'],
116
+ },
117
+ }
118
+
119
+ const mockAction = vi.fn(() =>
120
+ Promise.resolve({
121
+ ok: false as const,
122
+ error: complexError,
123
+ } as unknown as ShopActionResult<any>)
124
+ )
125
+
126
+ const {result} = renderHook(() => useHandleAction(mockAction))
127
+
128
+ await expect(result.current()).rejects.toEqual(complexError)
129
+ })
130
+
131
+ it('handles string errors', async () => {
132
+ const mockAction = vi.fn(() =>
133
+ Promise.resolve({
134
+ ok: false as const,
135
+ error: 'Simple error message',
136
+ } as unknown as ShopActionResult<any>)
137
+ )
138
+
139
+ const {result} = renderHook(() => useHandleAction(mockAction))
140
+
141
+ await expect(result.current()).rejects.toBe('Simple error message')
142
+ })
143
+ })
144
+
145
+ describe('Function Stability', () => {
146
+ it('maintains reference equality across renders', () => {
147
+ const mockAction = vi.fn(() =>
148
+ Promise.resolve({
149
+ ok: true as const,
150
+ data: 'test',
151
+ } as ShopActionResult<string>)
152
+ )
153
+
154
+ const {result, rerender} = renderHook(() => useHandleAction(mockAction))
155
+
156
+ const firstRender = result.current
157
+ rerender()
158
+ const secondRender = result.current
159
+
160
+ expect(firstRender).toBe(secondRender)
161
+ })
162
+
163
+ it('updates when action changes', () => {
164
+ const mockAction1 = vi.fn(() =>
165
+ Promise.resolve({
166
+ ok: true as const,
167
+ data: 'action1',
168
+ } as ShopActionResult<string>)
169
+ )
170
+
171
+ const mockAction2 = vi.fn(() =>
172
+ Promise.resolve({
173
+ ok: true as const,
174
+ data: 'action2',
175
+ } as ShopActionResult<string>)
176
+ )
177
+
178
+ const {result, rerender} = renderHook(
179
+ ({action}) => useHandleAction(action),
180
+ {initialProps: {action: mockAction1}}
181
+ )
182
+
183
+ const firstRender = result.current
184
+
185
+ rerender({action: mockAction2})
186
+ const secondRender = result.current
187
+
188
+ expect(firstRender).not.toBe(secondRender)
189
+ })
190
+ })
191
+
192
+ describe('Multiple Calls', () => {
193
+ it('handles multiple concurrent calls', async () => {
194
+ let callCount = 0
195
+ const mockAction = vi.fn(async () => {
196
+ const currentCall = ++callCount
197
+ await new Promise(resolve => setTimeout(resolve, 10))
198
+ return {
199
+ ok: true as const,
200
+ data: currentCall,
201
+ } as ShopActionResult<number>
202
+ })
203
+
204
+ const {result} = renderHook(() => useHandleAction(mockAction))
205
+
206
+ const [result1, result2, result3] = await Promise.all([
207
+ result.current(),
208
+ result.current(),
209
+ result.current(),
210
+ ])
211
+
212
+ expect(result1).toBe(1)
213
+ expect(result2).toBe(2)
214
+ expect(result3).toBe(3)
215
+ expect(mockAction).toHaveBeenCalledTimes(3)
216
+ })
217
+
218
+ it('handles sequential calls', async () => {
219
+ let counter = 0
220
+ const mockAction = vi.fn(
221
+ async () =>
222
+ ({
223
+ ok: true as const,
224
+ data: ++counter,
225
+ }) as ShopActionResult<number>
226
+ )
227
+
228
+ const {result} = renderHook(() => useHandleAction(mockAction))
229
+
230
+ const result1 = await result.current()
231
+ const result2 = await result.current()
232
+ const result3 = await result.current()
233
+
234
+ expect(result1).toBe(1)
235
+ expect(result2).toBe(2)
236
+ expect(result3).toBe(3)
237
+ })
238
+ })
239
+
240
+ describe('Promise Behavior', () => {
241
+ it('returns a promise', () => {
242
+ const mockAction = vi.fn(() =>
243
+ Promise.resolve({
244
+ ok: true as const,
245
+ data: 'test',
246
+ } as ShopActionResult<string>)
247
+ )
248
+
249
+ const {result} = renderHook(() => useHandleAction(mockAction))
250
+
251
+ const returnValue = result.current()
252
+
253
+ expect(returnValue).toBeInstanceOf(Promise)
254
+ })
255
+
256
+ it('handles rejected promises from action', async () => {
257
+ const networkError = new Error('Network error')
258
+ const mockAction = vi.fn(() => Promise.reject(networkError))
259
+
260
+ const {result} = renderHook(() => useHandleAction(mockAction))
261
+
262
+ await expect(result.current()).rejects.toThrow('Network error')
263
+ })
264
+ })
265
+ })