@navios/di-react 0.1.0

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.
@@ -0,0 +1,216 @@
1
+ import { Container, Injectable, InjectionToken, Registry } from '@navios/di'
2
+
3
+ import { act, render, screen, waitFor } from '@testing-library/react'
4
+ import { createElement, useMemo } from 'react'
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6
+ import { z } from 'zod/v4'
7
+
8
+ import { ContainerProvider } from '../../providers/container-provider.mjs'
9
+ import { useInvalidate, useInvalidateInstance } from '../use-invalidate.mjs'
10
+ import { useService } from '../use-service.mjs'
11
+
12
+ describe('useInvalidate', () => {
13
+ let container: Container
14
+ let registry: Registry
15
+
16
+ beforeEach(() => {
17
+ registry = new Registry()
18
+ container = new Container(registry)
19
+ })
20
+
21
+ afterEach(async () => {
22
+ await container.dispose()
23
+ vi.clearAllMocks()
24
+ })
25
+
26
+ const createWrapper = (children: React.ReactNode) =>
27
+ createElement(ContainerProvider, { container, children })
28
+
29
+ describe('with class tokens', () => {
30
+ it('should invalidate a service and trigger re-fetch', async () => {
31
+ let instanceCount = 0
32
+
33
+ @Injectable({ registry })
34
+ class CounterService {
35
+ public readonly id: number
36
+
37
+ constructor() {
38
+ instanceCount++
39
+ this.id = instanceCount
40
+ }
41
+ }
42
+
43
+ let invalidateFn: (() => Promise<void>) | null = null
44
+
45
+ function TestComponent() {
46
+ const { data, isSuccess } = useService(CounterService)
47
+ const invalidate = useInvalidate(CounterService)
48
+ invalidateFn = invalidate
49
+
50
+ if (!isSuccess) {
51
+ return createElement('div', { 'data-testid': 'loading' }, 'Loading...')
52
+ }
53
+
54
+ return createElement('div', { 'data-testid': 'counter' }, String(data!.id))
55
+ }
56
+
57
+ render(createWrapper(createElement(TestComponent)))
58
+
59
+ // Wait for initial load
60
+ await waitFor(() => {
61
+ expect(screen.getByTestId('counter')).toBeDefined()
62
+ })
63
+
64
+ expect(screen.getByTestId('counter').textContent).toBe('1')
65
+
66
+ // Invalidate the service
67
+ await act(async () => {
68
+ await invalidateFn!()
69
+ })
70
+
71
+ // Wait for re-fetch
72
+ await waitFor(() => {
73
+ expect(screen.getByTestId('counter').textContent).toBe('2')
74
+ })
75
+
76
+ expect(instanceCount).toBe(2)
77
+ })
78
+ })
79
+
80
+ describe('with injection tokens and args', () => {
81
+ it('should invalidate a service with specific args', async () => {
82
+ let instanceCount = 0
83
+ const instances = new Map<string, number>()
84
+
85
+ const UserSchema = z.object({ userId: z.string() })
86
+ const UserToken = InjectionToken.create<
87
+ { userId: string; instanceId: number },
88
+ typeof UserSchema
89
+ >('User', UserSchema)
90
+
91
+ @Injectable({ registry, token: UserToken })
92
+ class _UserService {
93
+ public userId: string
94
+ public instanceId: number
95
+
96
+ constructor(args: z.infer<typeof UserSchema>) {
97
+ this.userId = args.userId
98
+ const currentCount = instances.get(args.userId) ?? 0
99
+ instanceCount++
100
+ this.instanceId = instanceCount
101
+ instances.set(args.userId, this.instanceId)
102
+ }
103
+ }
104
+
105
+ let invalidateUser1: (() => Promise<void>) | null = null
106
+
107
+ function TestComponent() {
108
+ const args1 = useMemo(() => ({ userId: 'user1' }), [])
109
+ const args2 = useMemo(() => ({ userId: 'user2' }), [])
110
+
111
+ const { data: user1, isSuccess: isSuccess1 } = useService(UserToken, args1)
112
+ const { data: user2, isSuccess: isSuccess2 } = useService(UserToken, args2)
113
+ const invalidate1 = useInvalidate(UserToken, args1)
114
+ invalidateUser1 = invalidate1
115
+
116
+ if (!isSuccess1 || !isSuccess2) {
117
+ return createElement('div', { 'data-testid': 'loading' }, 'Loading...')
118
+ }
119
+
120
+ return createElement('div', null, [
121
+ createElement('span', { key: '1', 'data-testid': 'user1' }, String(user1!.instanceId)),
122
+ createElement('span', { key: '2', 'data-testid': 'user2' }, String(user2!.instanceId)),
123
+ ])
124
+ }
125
+
126
+ render(createWrapper(createElement(TestComponent)))
127
+
128
+ await waitFor(() => {
129
+ expect(screen.getByTestId('user1')).toBeDefined()
130
+ expect(screen.getByTestId('user2')).toBeDefined()
131
+ })
132
+
133
+ expect(screen.getByTestId('user1').textContent).toBe('1')
134
+ expect(screen.getByTestId('user2').textContent).toBe('2')
135
+
136
+ // Invalidate only user1
137
+ await act(async () => {
138
+ await invalidateUser1!()
139
+ })
140
+
141
+ // Wait for user1 to be re-fetched
142
+ await waitFor(() => {
143
+ expect(screen.getByTestId('user1').textContent).toBe('3')
144
+ })
145
+
146
+ // user2 should still be the same
147
+ expect(screen.getByTestId('user2').textContent).toBe('2')
148
+ })
149
+ })
150
+ })
151
+
152
+ describe('useInvalidateInstance', () => {
153
+ let container: Container
154
+ let registry: Registry
155
+
156
+ beforeEach(() => {
157
+ registry = new Registry()
158
+ container = new Container(registry)
159
+ })
160
+
161
+ afterEach(async () => {
162
+ await container.dispose()
163
+ vi.clearAllMocks()
164
+ })
165
+
166
+ const createWrapper = (children: React.ReactNode) =>
167
+ createElement(ContainerProvider, { container, children })
168
+
169
+ it('should invalidate a service instance directly', async () => {
170
+ let instanceCount = 0
171
+
172
+ @Injectable({ registry })
173
+ class CounterService {
174
+ public readonly id: number
175
+
176
+ constructor() {
177
+ instanceCount++
178
+ this.id = instanceCount
179
+ }
180
+ }
181
+
182
+ let invalidateInstanceFn: ((instance: unknown) => Promise<void>) | null = null
183
+ let currentInstance: CounterService | undefined
184
+
185
+ function TestComponent() {
186
+ const { data, isSuccess } = useService(CounterService)
187
+ const invalidateInstance = useInvalidateInstance()
188
+ invalidateInstanceFn = invalidateInstance
189
+ currentInstance = data
190
+
191
+ if (!isSuccess) {
192
+ return createElement('div', { 'data-testid': 'loading' }, 'Loading...')
193
+ }
194
+
195
+ return createElement('div', { 'data-testid': 'counter' }, String(data!.id))
196
+ }
197
+
198
+ render(createWrapper(createElement(TestComponent)))
199
+
200
+ await waitFor(() => {
201
+ expect(screen.getByTestId('counter')).toBeDefined()
202
+ })
203
+
204
+ expect(screen.getByTestId('counter').textContent).toBe('1')
205
+
206
+ // Invalidate the instance directly
207
+ await act(async () => {
208
+ await invalidateInstanceFn!(currentInstance!)
209
+ })
210
+
211
+ // Wait for re-fetch
212
+ await waitFor(() => {
213
+ expect(screen.getByTestId('counter').textContent).toBe('2')
214
+ })
215
+ })
216
+ })
@@ -0,0 +1,233 @@
1
+ import { Container, Injectable, InjectionToken, Registry } from '@navios/di'
2
+
3
+ import { render, screen, waitFor } from '@testing-library/react'
4
+ import { createElement, useMemo } from 'react'
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6
+ import { z } from 'zod/v4'
7
+
8
+ import { ContainerProvider } from '../../providers/container-provider.mjs'
9
+ import { useOptionalService } from '../use-optional-service.mjs'
10
+
11
+ describe('useOptionalService', () => {
12
+ let container: Container
13
+ let registry: Registry
14
+
15
+ beforeEach(() => {
16
+ registry = new Registry()
17
+ container = new Container(registry)
18
+ })
19
+
20
+ afterEach(async () => {
21
+ await container.dispose()
22
+ vi.clearAllMocks()
23
+ })
24
+
25
+ const createWrapper = (children: React.ReactNode) =>
26
+ createElement(ContainerProvider, { container, children })
27
+
28
+ describe('when service is registered', () => {
29
+ it('should load the service successfully', async () => {
30
+ @Injectable({ registry })
31
+ class TestService {
32
+ getValue() {
33
+ return 'test-value'
34
+ }
35
+ }
36
+
37
+ function TestComponent() {
38
+ const { data, isSuccess, isNotFound, isLoading } = useOptionalService(TestService)
39
+
40
+ if (isLoading) {
41
+ return createElement('div', { 'data-testid': 'loading' }, 'Loading...')
42
+ }
43
+
44
+ if (isNotFound) {
45
+ return createElement('div', { 'data-testid': 'not-found' }, 'Not Found')
46
+ }
47
+
48
+ if (isSuccess) {
49
+ return createElement('div', { 'data-testid': 'result' }, data!.getValue())
50
+ }
51
+
52
+ return createElement('div', { 'data-testid': 'idle' }, 'Idle')
53
+ }
54
+
55
+ render(createWrapper(createElement(TestComponent)))
56
+
57
+ await waitFor(() => {
58
+ expect(screen.getByTestId('result')).toBeDefined()
59
+ })
60
+
61
+ expect(screen.getByTestId('result').textContent).toBe('test-value')
62
+ })
63
+
64
+ it('should load service with injection token and args', async () => {
65
+ const UserSchema = z.object({ userId: z.string() })
66
+ const UserToken = InjectionToken.create<
67
+ { userId: string; name: string },
68
+ typeof UserSchema
69
+ >('User', UserSchema)
70
+
71
+ @Injectable({ registry, token: UserToken })
72
+ class _UserService {
73
+ public userId: string
74
+ public name: string
75
+
76
+ constructor(args: z.infer<typeof UserSchema>) {
77
+ this.userId = args.userId
78
+ this.name = `User ${args.userId}`
79
+ }
80
+ }
81
+
82
+ function TestComponent() {
83
+ const args = useMemo(() => ({ userId: '123' }), [])
84
+ const { data, isSuccess, isNotFound } = useOptionalService(UserToken, args)
85
+
86
+ if (isNotFound) {
87
+ return createElement('div', { 'data-testid': 'not-found' }, 'Not Found')
88
+ }
89
+
90
+ if (isSuccess) {
91
+ return createElement('div', { 'data-testid': 'result' }, data!.name)
92
+ }
93
+
94
+ return createElement('div', { 'data-testid': 'loading' }, 'Loading...')
95
+ }
96
+
97
+ render(createWrapper(createElement(TestComponent)))
98
+
99
+ await waitFor(() => {
100
+ expect(screen.getByTestId('result')).toBeDefined()
101
+ })
102
+
103
+ expect(screen.getByTestId('result').textContent).toBe('User 123')
104
+ })
105
+ })
106
+
107
+ describe('when service is not registered', () => {
108
+ it('should return isNotFound true for unregistered class token', async () => {
109
+ // Create a class that is NOT registered with Injectable
110
+ class UnregisteredService {
111
+ getValue() {
112
+ return 'value'
113
+ }
114
+ }
115
+
116
+ function TestComponent() {
117
+ const { isSuccess, isNotFound, isError, isLoading } =
118
+ useOptionalService(UnregisteredService)
119
+
120
+ if (isLoading) {
121
+ return createElement('div', { 'data-testid': 'loading' }, 'Loading...')
122
+ }
123
+
124
+ if (isNotFound) {
125
+ return createElement('div', { 'data-testid': 'not-found' }, 'Service Not Found')
126
+ }
127
+
128
+ if (isError) {
129
+ return createElement('div', { 'data-testid': 'error' }, 'Error')
130
+ }
131
+
132
+ if (isSuccess) {
133
+ return createElement('div', { 'data-testid': 'success' }, 'Success')
134
+ }
135
+
136
+ return createElement('div', { 'data-testid': 'idle' }, 'Idle')
137
+ }
138
+
139
+ render(createWrapper(createElement(TestComponent)))
140
+
141
+ await waitFor(() => {
142
+ const notFound = screen.queryByTestId('not-found')
143
+ const error = screen.queryByTestId('error')
144
+ // Either not-found or error is acceptable for unregistered services
145
+ expect(notFound || error).toBeTruthy()
146
+ })
147
+ })
148
+
149
+ it('should return isNotFound true for unregistered injection token', async () => {
150
+ const UnregisteredToken = InjectionToken.create<{ value: string }>('Unregistered')
151
+
152
+ function TestComponent() {
153
+ const { isSuccess, isNotFound, isError, isLoading } =
154
+ useOptionalService(UnregisteredToken)
155
+
156
+ if (isLoading) {
157
+ return createElement('div', { 'data-testid': 'loading' }, 'Loading...')
158
+ }
159
+
160
+ if (isNotFound) {
161
+ return createElement('div', { 'data-testid': 'not-found' }, 'Token Not Found')
162
+ }
163
+
164
+ if (isError) {
165
+ return createElement('div', { 'data-testid': 'error' }, 'Error')
166
+ }
167
+
168
+ if (isSuccess) {
169
+ return createElement('div', { 'data-testid': 'success' }, 'Success')
170
+ }
171
+
172
+ return createElement('div', { 'data-testid': 'idle' }, 'Idle')
173
+ }
174
+
175
+ render(createWrapper(createElement(TestComponent)))
176
+
177
+ await waitFor(() => {
178
+ const notFound = screen.queryByTestId('not-found')
179
+ const error = screen.queryByTestId('error')
180
+ // Either not-found or error is acceptable for unregistered tokens
181
+ expect(notFound || error).toBeTruthy()
182
+ })
183
+ })
184
+ })
185
+
186
+ describe('refetch functionality', () => {
187
+ it('should allow manual refetch', async () => {
188
+ let instanceCount = 0
189
+
190
+ @Injectable({ registry })
191
+ class CounterService {
192
+ public readonly id: number
193
+
194
+ constructor() {
195
+ instanceCount++
196
+ this.id = instanceCount
197
+ }
198
+ }
199
+
200
+ let refetchFn: (() => void) | null = null
201
+
202
+ function TestComponent() {
203
+ const { data, isSuccess, refetch } = useOptionalService(CounterService)
204
+ refetchFn = refetch
205
+
206
+ if (!isSuccess) {
207
+ return createElement('div', { 'data-testid': 'loading' }, 'Loading...')
208
+ }
209
+
210
+ return createElement('div', { 'data-testid': 'counter' }, String(data!.id))
211
+ }
212
+
213
+ render(createWrapper(createElement(TestComponent)))
214
+
215
+ await waitFor(() => {
216
+ expect(screen.getByTestId('counter')).toBeDefined()
217
+ })
218
+
219
+ expect(screen.getByTestId('counter').textContent).toBe('1')
220
+
221
+ // Note: refetch alone won't create a new instance since the service is cached
222
+ // It will return the same cached instance
223
+ refetchFn!()
224
+
225
+ await waitFor(() => {
226
+ expect(screen.getByTestId('counter')).toBeDefined()
227
+ })
228
+
229
+ // Same instance because it's cached
230
+ expect(screen.getByTestId('counter').textContent).toBe('1')
231
+ })
232
+ })
233
+ })
@@ -0,0 +1,212 @@
1
+ import { Container, Injectable, InjectionToken, Registry } from '@navios/di'
2
+
3
+ import { act, renderHook, waitFor } from '@testing-library/react'
4
+ import { createElement, useMemo } from 'react'
5
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
6
+ import { z } from 'zod/v4'
7
+
8
+ import { ContainerProvider } from '../../providers/container-provider.mjs'
9
+ import { useService } from '../use-service.mjs'
10
+
11
+ describe('useService', () => {
12
+ let container: Container
13
+ let registry: Registry
14
+
15
+ beforeEach(() => {
16
+ registry = new Registry()
17
+ container = new Container(registry)
18
+ })
19
+
20
+ afterEach(async () => {
21
+ await container.clear()
22
+ })
23
+
24
+ const createWrapper = () => {
25
+ return ({ children }: { children: React.ReactNode }) =>
26
+ // @ts-expect-error - container is required
27
+ createElement(ContainerProvider, { container }, children)
28
+ }
29
+
30
+ describe('with class tokens', () => {
31
+ it('should load a service and return success state', async () => {
32
+ @Injectable({ registry })
33
+ class TestService {
34
+ getValue() {
35
+ return 'test-value'
36
+ }
37
+ }
38
+
39
+ const { result } = renderHook(() => useService(TestService), {
40
+ wrapper: createWrapper(),
41
+ })
42
+
43
+ // Initially loading
44
+ expect(result.current.isLoading).toBe(true)
45
+ expect(result.current.data).toBeUndefined()
46
+
47
+ // Wait for success
48
+ await waitFor(() => {
49
+ expect(result.current.isSuccess).toBe(true)
50
+ })
51
+
52
+ expect(result.current.data).toBeInstanceOf(TestService)
53
+ expect(result.current.data?.getValue()).toBe('test-value')
54
+ expect(result.current.isLoading).toBe(false)
55
+ expect(result.current.isError).toBe(false)
56
+ })
57
+
58
+ it('should return error state when service fails to load', async () => {
59
+ @Injectable({ registry })
60
+ class FailingService {
61
+ constructor() {
62
+ throw new Error('Service initialization failed')
63
+ }
64
+ }
65
+
66
+ const { result } = renderHook(() => useService(FailingService), {
67
+ wrapper: createWrapper(),
68
+ })
69
+
70
+ await waitFor(() => {
71
+ expect(result.current.isError).toBe(true)
72
+ })
73
+
74
+ expect(result.current.error?.message).toBe(
75
+ 'Service initialization failed',
76
+ )
77
+ expect(result.current.data).toBeUndefined()
78
+ expect(result.current.isLoading).toBe(false)
79
+ expect(result.current.isSuccess).toBe(false)
80
+ })
81
+
82
+ it.skip('should refetch service when refetch is called', async () => {
83
+ let callCount = 0
84
+
85
+ @Injectable({ registry })
86
+ class CountingService {
87
+ public count: number
88
+
89
+ constructor() {
90
+ callCount++
91
+ this.count = callCount
92
+ }
93
+ }
94
+
95
+ const { result } = renderHook(() => useService(CountingService), {
96
+ wrapper: createWrapper(),
97
+ })
98
+
99
+ await waitFor(() => {
100
+ expect(result.current.isSuccess).toBe(true)
101
+ })
102
+
103
+ expect(result.current.data?.count).toBe(1)
104
+
105
+ // Invalidate the service first so refetch creates a new instance
106
+ await container.invalidate(result.current.data)
107
+
108
+ // Trigger refetch
109
+ await act(async () => {
110
+ result.current.refetch()
111
+ })
112
+
113
+ await waitFor(() => {
114
+ expect(result.current.data?.count).toBe(2)
115
+ })
116
+ })
117
+ })
118
+
119
+ describe('with injection tokens', () => {
120
+ it('should load a service with injection token', async () => {
121
+ const TestToken = InjectionToken.create<{ value: string }>('TestToken')
122
+
123
+ @Injectable({ registry, token: TestToken })
124
+ class TestService {
125
+ value = 'token-value'
126
+ }
127
+
128
+ const { result } = renderHook(() => useService(TestToken), {
129
+ wrapper: createWrapper(),
130
+ })
131
+
132
+ await waitFor(() => {
133
+ expect(result.current.isSuccess).toBe(true)
134
+ })
135
+
136
+ expect(result.current.data?.value).toBe('token-value')
137
+ })
138
+
139
+ it('should load a service with injection token and args', async () => {
140
+ const UserSchema = z.object({ userId: z.string() })
141
+ const UserToken = InjectionToken.create<
142
+ { userId: string; name: string },
143
+ typeof UserSchema
144
+ >('User', UserSchema)
145
+
146
+ @Injectable({ registry, token: UserToken })
147
+ class UserService {
148
+ public userId: string
149
+ public name: string
150
+
151
+ constructor(args: z.infer<typeof UserSchema>) {
152
+ this.userId = args.userId
153
+ this.name = `User-${args.userId}`
154
+ }
155
+ }
156
+
157
+ const { result } = renderHook(
158
+ () => {
159
+ const args = useMemo(() => ({ userId: '123' }), [])
160
+ return useService(UserToken, args)
161
+ },
162
+ { wrapper: createWrapper() },
163
+ )
164
+
165
+ await waitFor(() => {
166
+ expect(result.current.isSuccess).toBe(true)
167
+ })
168
+
169
+ expect(result.current.data?.userId).toBe('123')
170
+ expect(result.current.data?.name).toBe('User-123')
171
+ })
172
+ })
173
+
174
+ describe('service invalidation', () => {
175
+ it('should re-fetch when service is invalidated', async () => {
176
+ let instanceCount = 0
177
+
178
+ @Injectable({ registry })
179
+ class InvalidatableService {
180
+ public instanceId: number
181
+
182
+ constructor() {
183
+ instanceCount++
184
+ this.instanceId = instanceCount
185
+ }
186
+ }
187
+
188
+ const { result } = renderHook(() => useService(InvalidatableService), {
189
+ wrapper: createWrapper(),
190
+ })
191
+
192
+ await waitFor(() => {
193
+ expect(result.current.isSuccess).toBe(true)
194
+ })
195
+
196
+ expect(result.current.data?.instanceId).toBe(1)
197
+
198
+ // Invalidate the service
199
+ await act(async () => {
200
+ await container.invalidate(result.current.data)
201
+ })
202
+
203
+ // Wait for re-fetch with increased timeout for event propagation
204
+ await waitFor(
205
+ () => {
206
+ expect(result.current.data?.instanceId).toBe(2)
207
+ },
208
+ { timeout: 2000 },
209
+ )
210
+ })
211
+ })
212
+ })