@shopify/shop-minis-react 0.1.0 → 0.1.2

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 (39) hide show
  1. package/dist/_virtual/index4.js +3 -2
  2. package/dist/_virtual/index4.js.map +1 -1
  3. package/dist/_virtual/index5.js +2 -3
  4. package/dist/_virtual/index5.js.map +1 -1
  5. package/dist/_virtual/index6.js +3 -2
  6. package/dist/_virtual/index6.js.map +1 -1
  7. package/dist/_virtual/index7.js +2 -3
  8. package/dist/_virtual/index7.js.map +1 -1
  9. package/dist/components/atoms/image.js +45 -33
  10. package/dist/components/atoms/image.js.map +1 -1
  11. package/dist/components/commerce/product-card.js +20 -20
  12. package/dist/components/commerce/product-card.js.map +1 -1
  13. package/dist/hooks/events/useOnAppStateChange.js +14 -0
  14. package/dist/hooks/events/useOnAppStateChange.js.map +1 -0
  15. package/dist/hooks/events/useOnMiniBlur.js +14 -0
  16. package/dist/hooks/events/useOnMiniBlur.js.map +1 -0
  17. package/dist/hooks/events/useOnMiniClose.js +14 -0
  18. package/dist/hooks/events/useOnMiniClose.js.map +1 -0
  19. package/dist/hooks/events/useOnMiniFocus.js +14 -0
  20. package/dist/hooks/events/useOnMiniFocus.js.map +1 -0
  21. package/dist/hooks/user/useGenerateUserToken.js +28 -6
  22. package/dist/hooks/user/useGenerateUserToken.js.map +1 -1
  23. package/dist/index.js +114 -106
  24. package/dist/index.js.map +1 -1
  25. package/dist/shop-minis-react/node_modules/.pnpm/@radix-ui_react-use-is-hydrated@0.1.0_@types_react@19.1.6_react@19.1.0/node_modules/@radix-ui/react-use-is-hydrated/dist/index.js +1 -1
  26. package/dist/shop-minis-react/node_modules/.pnpm/@videojs_xhr@2.7.0/node_modules/@videojs/xhr/lib/index.js +1 -1
  27. package/dist/shop-minis-react/node_modules/.pnpm/mpd-parser@1.3.1/node_modules/mpd-parser/dist/mpd-parser.es.js +1 -1
  28. package/dist/shop-minis-react/node_modules/.pnpm/querystringify@2.2.0/node_modules/querystringify/index.js +1 -1
  29. package/package.json +2 -2
  30. package/src/components/atoms/image.tsx +34 -4
  31. package/src/components/commerce/product-card.tsx +3 -3
  32. package/src/hooks/events/useOnAppStateChange.ts +20 -0
  33. package/src/hooks/events/useOnMiniBlur.ts +16 -0
  34. package/src/hooks/events/useOnMiniClose.ts +16 -0
  35. package/src/hooks/events/useOnMiniFocus.ts +16 -0
  36. package/src/hooks/index.ts +6 -0
  37. package/src/hooks/user/useGenerateUserToken.test.ts +340 -0
  38. package/src/hooks/user/useGenerateUserToken.ts +72 -2
  39. package/src/stories/ImageContentWrapper.stories.tsx +1 -4
@@ -0,0 +1,340 @@
1
+ import {renderHook, act} from '@testing-library/react'
2
+ import {describe, expect, it, vi, beforeEach} from 'vitest'
3
+
4
+ import {useHandleAction} from '../../internal/useHandleAction'
5
+ import {useShopActions} from '../../internal/useShopActions'
6
+
7
+ import {useGenerateUserToken} from './useGenerateUserToken'
8
+
9
+ // Mock the internal hooks
10
+ vi.mock('../../internal/useShopActions', () => ({
11
+ useShopActions: vi.fn(),
12
+ }))
13
+
14
+ vi.mock('../../internal/useHandleAction', () => ({
15
+ useHandleAction: vi.fn((action: any) => action),
16
+ }))
17
+
18
+ describe('useGenerateUserToken', () => {
19
+ const mockToken = 'test-token-123'
20
+ const mockUserState = 'VERIFIED'
21
+
22
+ // Helper to create a future timestamp
23
+ const getFutureTimestamp = (hoursFromNow: number) => {
24
+ const date = new Date()
25
+ date.setHours(date.getHours() + hoursFromNow)
26
+ return date.toISOString()
27
+ }
28
+
29
+ // Helper to create an expired timestamp
30
+ const getExpiredTimestamp = () => {
31
+ const date = new Date()
32
+ date.setHours(date.getHours() - 1)
33
+ return date.toISOString()
34
+ }
35
+
36
+ const createMockResponse = (overrides = {}) => ({
37
+ data: {
38
+ token: mockToken,
39
+ expiresAt: getFutureTimestamp(24), // 24 hours from now
40
+ userState: mockUserState,
41
+ ...overrides,
42
+ },
43
+ userErrors: [],
44
+ })
45
+
46
+ let mockGenerateUserToken: ReturnType<typeof vi.fn>
47
+
48
+ beforeEach(() => {
49
+ vi.clearAllMocks()
50
+
51
+ mockGenerateUserToken = vi.fn()
52
+ ;(useShopActions as ReturnType<typeof vi.fn>).mockReturnValue({
53
+ generateUserToken: mockGenerateUserToken,
54
+ })
55
+ ;(useHandleAction as ReturnType<typeof vi.fn>).mockImplementation(
56
+ (action: any) => action
57
+ )
58
+ })
59
+
60
+ describe('Token Generation', () => {
61
+ it('should generate a new token on first call', async () => {
62
+ mockGenerateUserToken.mockResolvedValue(createMockResponse())
63
+
64
+ const {result} = renderHook(() => useGenerateUserToken())
65
+
66
+ let tokenResponse: any
67
+ await act(async () => {
68
+ tokenResponse = await result.current.generateUserToken()
69
+ })
70
+
71
+ expect(tokenResponse.data).toEqual({
72
+ token: mockToken,
73
+ expiresAt: expect.any(String),
74
+ userState: mockUserState,
75
+ })
76
+ expect(mockGenerateUserToken).toHaveBeenCalledTimes(1)
77
+ })
78
+
79
+ it('should throw error when token generation fails', async () => {
80
+ const error = new Error('Network error')
81
+ mockGenerateUserToken.mockRejectedValue(error)
82
+
83
+ const {result} = renderHook(() => useGenerateUserToken())
84
+
85
+ await expect(result.current.generateUserToken()).rejects.toThrow(
86
+ 'Network error'
87
+ )
88
+ expect(mockGenerateUserToken).toHaveBeenCalledTimes(1)
89
+ })
90
+
91
+ it('should return response even when token is incomplete', async () => {
92
+ const incompleteResponse = {
93
+ data: {
94
+ token: null,
95
+ expiresAt: null,
96
+ userState: null,
97
+ },
98
+ userErrors: [
99
+ {code: 'INVALID_TOKEN', message: 'Failed to generate token'},
100
+ ],
101
+ }
102
+
103
+ mockGenerateUserToken.mockResolvedValue(incompleteResponse)
104
+
105
+ const {result} = renderHook(() => useGenerateUserToken())
106
+
107
+ const response = await result.current.generateUserToken()
108
+
109
+ expect(response).toEqual(incompleteResponse)
110
+ expect(mockGenerateUserToken).toHaveBeenCalledTimes(1)
111
+ })
112
+ })
113
+
114
+ describe('Token Caching', () => {
115
+ it('should return cached token on subsequent calls', async () => {
116
+ mockGenerateUserToken.mockResolvedValue(createMockResponse())
117
+
118
+ const {result} = renderHook(() => useGenerateUserToken())
119
+
120
+ // First call - should hit the API
121
+ let firstResponse: any
122
+ await act(async () => {
123
+ firstResponse = await result.current.generateUserToken()
124
+ })
125
+
126
+ // Second call - should return cached token
127
+ let secondResponse: any
128
+ await act(async () => {
129
+ secondResponse = await result.current.generateUserToken()
130
+ })
131
+
132
+ // Both should be the same reference (cached)
133
+ expect(firstResponse).toBe(secondResponse)
134
+ expect(secondResponse.data.token).toBe(mockToken)
135
+ expect(mockGenerateUserToken).toHaveBeenCalledTimes(1) // Only called once
136
+ })
137
+
138
+ it('should request new token when cached token is expired', async () => {
139
+ mockGenerateUserToken
140
+ .mockResolvedValueOnce(
141
+ createMockResponse({
142
+ token: 'expired-token',
143
+ expiresAt: getExpiredTimestamp(),
144
+ })
145
+ )
146
+ .mockResolvedValueOnce(
147
+ createMockResponse({
148
+ token: 'new-token',
149
+ expiresAt: getFutureTimestamp(24),
150
+ })
151
+ )
152
+
153
+ const {result} = renderHook(() => useGenerateUserToken())
154
+
155
+ // First call - gets expired token
156
+ await act(async () => {
157
+ await result.current.generateUserToken()
158
+ })
159
+
160
+ // Second call - should request new token since first is expired
161
+ let secondResponse: any
162
+ await act(async () => {
163
+ secondResponse = await result.current.generateUserToken()
164
+ })
165
+
166
+ expect(secondResponse.data.token).toBe('new-token')
167
+ expect(mockGenerateUserToken).toHaveBeenCalledTimes(2)
168
+ })
169
+
170
+ it('should request new token when cached token is within 5-minute buffer', async () => {
171
+ // Token expires in 4 minutes (within buffer)
172
+ mockGenerateUserToken
173
+ .mockResolvedValueOnce(
174
+ createMockResponse({
175
+ token: 'almost-expired-token',
176
+ expiresAt: getFutureTimestamp(0.066), // ~4 minutes
177
+ })
178
+ )
179
+ .mockResolvedValueOnce(
180
+ createMockResponse({
181
+ token: 'fresh-token',
182
+ expiresAt: getFutureTimestamp(24),
183
+ })
184
+ )
185
+
186
+ const {result} = renderHook(() => useGenerateUserToken())
187
+
188
+ // First call
189
+ await act(async () => {
190
+ await result.current.generateUserToken()
191
+ })
192
+
193
+ // Second call - should get new token due to buffer
194
+ let secondResponse: any
195
+ await act(async () => {
196
+ secondResponse = await result.current.generateUserToken()
197
+ })
198
+
199
+ expect(secondResponse.data.token).toBe('fresh-token')
200
+ expect(mockGenerateUserToken).toHaveBeenCalledTimes(2)
201
+ })
202
+
203
+ it('should use cached token when outside 5-minute buffer', async () => {
204
+ // Token expires in 1 hour (well outside the 5-minute buffer)
205
+ const mockResponse = createMockResponse({
206
+ token: 'valid-token',
207
+ expiresAt: getFutureTimestamp(1), // 1 hour from now
208
+ })
209
+
210
+ mockGenerateUserToken.mockResolvedValue(mockResponse)
211
+
212
+ const {result} = renderHook(() => useGenerateUserToken())
213
+
214
+ // First call
215
+ const firstResponse = await result.current.generateUserToken()
216
+
217
+ // Verify the response has the expected structure
218
+ expect(firstResponse.data.token).toBe('valid-token')
219
+ expect(firstResponse.data.expiresAt).toBeDefined()
220
+
221
+ // Second call - should use cached token
222
+ const secondResponse = await result.current.generateUserToken()
223
+
224
+ // Both responses should be the same cached object
225
+ expect(firstResponse).toBe(secondResponse)
226
+ expect(mockGenerateUserToken).toHaveBeenCalledTimes(1)
227
+ })
228
+ })
229
+
230
+ describe('Race Condition Prevention', () => {
231
+ it('should handle concurrent requests by returning same promise', async () => {
232
+ // Create a delayed promise to simulate an API call
233
+ const mockResponse = createMockResponse()
234
+ mockGenerateUserToken.mockImplementation(
235
+ () =>
236
+ new Promise(resolve => {
237
+ setTimeout(() => resolve(mockResponse), 10)
238
+ })
239
+ )
240
+
241
+ const {result} = renderHook(() => useGenerateUserToken())
242
+
243
+ // Make two concurrent calls
244
+ const promise1 = result.current.generateUserToken()
245
+ const promise2 = result.current.generateUserToken()
246
+
247
+ // Only one API call should be made
248
+ expect(mockGenerateUserToken).toHaveBeenCalledTimes(1)
249
+
250
+ // Wait for both promises
251
+ const [response1, response2] = await Promise.all([promise1, promise2])
252
+
253
+ // Both should return the same response
254
+ expect(response1).toEqual(mockResponse)
255
+ expect(response2).toEqual(mockResponse)
256
+ expect(response1).toBe(response2)
257
+ })
258
+
259
+ it('should handle multiple rapid sequential calls correctly', async () => {
260
+ mockGenerateUserToken.mockResolvedValue(createMockResponse())
261
+
262
+ const {result} = renderHook(() => useGenerateUserToken())
263
+
264
+ // Make multiple rapid calls
265
+ const promises = Array.from({length: 5}, () =>
266
+ result.current.generateUserToken()
267
+ )
268
+
269
+ // All should resolve to the same token
270
+ const responses = await Promise.all(promises)
271
+
272
+ responses.forEach(response => {
273
+ expect(response.data.token).toBe(mockToken)
274
+ // All should be the same object reference
275
+ expect(response).toBe(responses[0])
276
+ })
277
+
278
+ // Should only have made one API call
279
+ expect(mockGenerateUserToken).toHaveBeenCalledTimes(1)
280
+ })
281
+ })
282
+
283
+ describe('Error Handling', () => {
284
+ it('should clear cache on error and retry', async () => {
285
+ mockGenerateUserToken
286
+ .mockRejectedValueOnce(new Error('Network error'))
287
+ .mockResolvedValueOnce(createMockResponse())
288
+
289
+ const {result} = renderHook(() => useGenerateUserToken())
290
+
291
+ // First call - should fail
292
+ await expect(result.current.generateUserToken()).rejects.toThrow(
293
+ 'Network error'
294
+ )
295
+
296
+ // Second call - should succeed with new request
297
+ let response: any
298
+ await act(async () => {
299
+ response = await result.current.generateUserToken()
300
+ })
301
+
302
+ expect(response.data.token).toBe(mockToken)
303
+ expect(mockGenerateUserToken).toHaveBeenCalledTimes(2)
304
+ })
305
+
306
+ it('should clear pending request on error', async () => {
307
+ let rejectPromise: any
308
+ const promise = new Promise((_resolve, reject) => {
309
+ rejectPromise = reject
310
+ })
311
+
312
+ mockGenerateUserToken.mockReturnValue(promise)
313
+
314
+ const {result} = renderHook(() => useGenerateUserToken())
315
+
316
+ // Make concurrent calls that will fail
317
+ const promise1 = result.current.generateUserToken()
318
+ const promise2 = result.current.generateUserToken()
319
+
320
+ // Reject the promise
321
+ rejectPromise(new Error('Network error'))
322
+
323
+ // Both should fail with same error
324
+ await expect(promise1).rejects.toThrow('Network error')
325
+ await expect(promise2).rejects.toThrow('Network error')
326
+
327
+ // Reset mock for next call
328
+ mockGenerateUserToken.mockResolvedValue(createMockResponse())
329
+
330
+ // Next call should make a fresh request
331
+ let response: any
332
+ await act(async () => {
333
+ response = await result.current.generateUserToken()
334
+ })
335
+
336
+ expect(response.data.token).toBe(mockToken)
337
+ expect(mockGenerateUserToken).toHaveBeenCalledTimes(2) // First failed call + retry
338
+ })
339
+ })
340
+ })
@@ -1,3 +1,5 @@
1
+ import {useCallback, useRef} from 'react'
2
+
1
3
  import {
2
4
  GeneratedTokenData,
3
5
  UserTokenGenerateUserErrors,
@@ -9,6 +11,8 @@ import {useShopActions} from '../../internal/useShopActions'
9
11
  interface UseGenerateUserTokenReturns {
10
12
  /**
11
13
  * Generates a temporary token for the user.
14
+ * Tokens are cached in memory and reused if still valid (with a 5-minute expiry buffer).
15
+ * A new token is automatically generated when the cached token is expired or missing.
12
16
  */
13
17
  generateUserToken: () => Promise<{
14
18
  data: GeneratedTokenData
@@ -16,10 +20,76 @@ interface UseGenerateUserTokenReturns {
16
20
  }>
17
21
  }
18
22
 
23
+ interface CachedTokenResponse {
24
+ data: GeneratedTokenData
25
+ userErrors?: UserTokenGenerateUserErrors[]
26
+ }
27
+
19
28
  export function useGenerateUserToken(): UseGenerateUserTokenReturns {
20
- const {generateUserToken} = useShopActions()
29
+ const {generateUserToken: generateUserTokenAction} = useShopActions()
30
+ const wrappedGenerateToken = useHandleAction(generateUserTokenAction)
31
+
32
+ const cachedResponse = useRef<CachedTokenResponse | null>(null)
33
+ const pendingRequest = useRef<Promise<CachedTokenResponse> | null>(null)
34
+
35
+ const isTokenValid = useCallback(
36
+ (response: CachedTokenResponse | null): boolean => {
37
+ if (!response?.data?.token || !response.data.expiresAt) {
38
+ return false
39
+ }
40
+
41
+ try {
42
+ const expiryTime = new Date(response.data.expiresAt).getTime()
43
+ const now = Date.now()
44
+ // 5 minutes buffer to ensure token doesn't expire mid-request
45
+ const bufferTime = 5 * 60 * 1000
46
+
47
+ return expiryTime - bufferTime > now
48
+ } catch {
49
+ // If date parsing fails, consider token invalid
50
+ return false
51
+ }
52
+ },
53
+ []
54
+ )
55
+
56
+ const generateUserToken =
57
+ useCallback(async (): Promise<CachedTokenResponse> => {
58
+ // Check if cached token exists and is still valid
59
+ if (cachedResponse.current && isTokenValid(cachedResponse.current)) {
60
+ return cachedResponse.current
61
+ }
62
+
63
+ // If there's already a pending request, return the same promise
64
+ if (pendingRequest.current) {
65
+ return pendingRequest.current
66
+ }
67
+
68
+ // Create new request and store the promise
69
+ pendingRequest.current = (async () => {
70
+ try {
71
+ const response = await wrappedGenerateToken()
72
+
73
+ // Only cache if we got a valid token
74
+ if (response.data?.token && response.data?.expiresAt) {
75
+ cachedResponse.current = response
76
+ }
77
+
78
+ return response
79
+ } catch (error) {
80
+ // Clear cache on error to ensure fresh token on next attempt
81
+ cachedResponse.current = null
82
+ throw error
83
+ } finally {
84
+ // Clear pending request after completion (success or failure)
85
+ pendingRequest.current = null
86
+ }
87
+ })()
88
+
89
+ return pendingRequest.current
90
+ }, [wrappedGenerateToken, isTokenValid])
21
91
 
22
92
  return {
23
- generateUserToken: useHandleAction(generateUserToken),
93
+ generateUserToken,
24
94
  }
25
95
  }
@@ -6,7 +6,7 @@ import {injectMocks} from '../mocks'
6
6
  import type {Meta, StoryObj} from '@storybook/react-vite'
7
7
 
8
8
  const meta = {
9
- title: 'Content/ImageContentWrapper',
9
+ title: 'Atoms/ImageContentWrapper',
10
10
  component: ImageContentWrapper,
11
11
  parameters: {
12
12
  layout: 'padded',
@@ -30,9 +30,6 @@ const meta = {
30
30
  className: {
31
31
  control: 'text',
32
32
  },
33
- Loader: {
34
- control: 'text',
35
- },
36
33
  },
37
34
  tags: ['autodocs'],
38
35
  } satisfies Meta<typeof ImageContentWrapper>