@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.
- package/LICENSE +7 -0
- package/README.md +176 -0
- package/lib/_tsup-dts-rollup.d.mts +304 -0
- package/lib/_tsup-dts-rollup.d.ts +304 -0
- package/lib/index.d.mts +18 -0
- package/lib/index.d.ts +18 -0
- package/lib/index.js +405 -0
- package/lib/index.js.map +1 -0
- package/lib/index.mjs +392 -0
- package/lib/index.mjs.map +1 -0
- package/package.json +44 -0
- package/project.json +61 -0
- package/src/hooks/__tests__/use-container.spec.mts +52 -0
- package/src/hooks/__tests__/use-invalidate.spec.mts +216 -0
- package/src/hooks/__tests__/use-optional-service.spec.mts +233 -0
- package/src/hooks/__tests__/use-service.spec.mts +212 -0
- package/src/hooks/__tests__/use-suspense-service.spec.mts +286 -0
- package/src/hooks/index.mts +8 -0
- package/src/hooks/use-container.mts +13 -0
- package/src/hooks/use-invalidate.mts +122 -0
- package/src/hooks/use-optional-service.mts +259 -0
- package/src/hooks/use-scope.mts +26 -0
- package/src/hooks/use-service.mts +201 -0
- package/src/hooks/use-suspense-service.mts +222 -0
- package/src/index.mts +8 -0
- package/src/providers/__tests__/scope-provider.spec.mts +280 -0
- package/src/providers/container-provider.mts +22 -0
- package/src/providers/context.mts +5 -0
- package/src/providers/index.mts +5 -0
- package/src/providers/scope-provider.mts +88 -0
- package/src/types.mts +21 -0
- package/tsconfig.json +13 -0
- package/tsup.config.mts +13 -0
- package/vitest.config.mts +9 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { Container, Injectable, InjectableScope, Registry } from '@navios/di'
|
|
2
|
+
|
|
3
|
+
import { render, screen, waitFor } from '@testing-library/react'
|
|
4
|
+
import { createElement } from 'react'
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
6
|
+
|
|
7
|
+
import { useService } from '../../hooks/use-service.mjs'
|
|
8
|
+
import { useScope } from '../../hooks/use-scope.mjs'
|
|
9
|
+
import { ContainerProvider } from '../container-provider.mjs'
|
|
10
|
+
import { ScopeProvider } from '../scope-provider.mjs'
|
|
11
|
+
|
|
12
|
+
describe('ScopeProvider', () => {
|
|
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('useScope', () => {
|
|
30
|
+
it('should return null when not inside ScopeProvider', () => {
|
|
31
|
+
let scopeValue: string | null = 'not-set'
|
|
32
|
+
|
|
33
|
+
function TestComponent() {
|
|
34
|
+
scopeValue = useScope()
|
|
35
|
+
return createElement('div', { 'data-testid': 'test' }, 'Test')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
render(createWrapper(createElement(TestComponent)))
|
|
39
|
+
|
|
40
|
+
expect(scopeValue).toBeNull()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('should return scope ID when inside ScopeProvider', () => {
|
|
44
|
+
let scopeValue: string | null = null
|
|
45
|
+
|
|
46
|
+
function TestComponent() {
|
|
47
|
+
scopeValue = useScope()
|
|
48
|
+
return createElement(
|
|
49
|
+
'div',
|
|
50
|
+
{ 'data-testid': 'test' },
|
|
51
|
+
scopeValue ?? 'no-scope',
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
render(
|
|
56
|
+
createWrapper(
|
|
57
|
+
createElement(
|
|
58
|
+
ScopeProvider,
|
|
59
|
+
// @ts-expect-error - props are not typed
|
|
60
|
+
{ scopeId: 'test-scope' },
|
|
61
|
+
createElement(TestComponent),
|
|
62
|
+
),
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
expect(scopeValue).toBe('test-scope')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('should generate unique scope ID when not provided', () => {
|
|
70
|
+
let scopeValue: string | null = null
|
|
71
|
+
|
|
72
|
+
function TestComponent() {
|
|
73
|
+
scopeValue = useScope()
|
|
74
|
+
return createElement(
|
|
75
|
+
'div',
|
|
76
|
+
{ 'data-testid': 'test' },
|
|
77
|
+
scopeValue ?? 'no-scope',
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
render(
|
|
82
|
+
createWrapper(
|
|
83
|
+
createElement(ScopeProvider, null, createElement(TestComponent)),
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
expect(scopeValue).not.toBeNull()
|
|
88
|
+
expect(typeof scopeValue).toBe('string')
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('request-scoped services', () => {
|
|
93
|
+
it('should create separate instances for different scopes', async () => {
|
|
94
|
+
let instanceCount = 0
|
|
95
|
+
|
|
96
|
+
@Injectable({ registry, scope: InjectableScope.Request })
|
|
97
|
+
class RequestScopedService {
|
|
98
|
+
public readonly id: number
|
|
99
|
+
|
|
100
|
+
constructor() {
|
|
101
|
+
instanceCount++
|
|
102
|
+
this.id = instanceCount
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function ServiceDisplay({ testId }: { testId: string }) {
|
|
107
|
+
const { data, isSuccess } = useService(RequestScopedService)
|
|
108
|
+
|
|
109
|
+
if (!isSuccess) {
|
|
110
|
+
return createElement(
|
|
111
|
+
'div',
|
|
112
|
+
{ 'data-testid': `${testId}-loading` },
|
|
113
|
+
'Loading...',
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return createElement('div', { 'data-testid': testId }, String(data!.id))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
render(
|
|
121
|
+
createWrapper(
|
|
122
|
+
createElement('div', null, [
|
|
123
|
+
createElement(
|
|
124
|
+
ScopeProvider,
|
|
125
|
+
// @ts-expect-error - props are not typed
|
|
126
|
+
{ key: 'scope1', scopeId: 'scope-1' },
|
|
127
|
+
createElement(ServiceDisplay, { testId: 'service-1' }),
|
|
128
|
+
),
|
|
129
|
+
createElement(
|
|
130
|
+
ScopeProvider,
|
|
131
|
+
// @ts-expect-error - props are not typed
|
|
132
|
+
{ key: 'scope2', scopeId: 'scope-2' },
|
|
133
|
+
createElement(ServiceDisplay, { testId: 'service-2' }),
|
|
134
|
+
),
|
|
135
|
+
]),
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
await waitFor(() => {
|
|
140
|
+
expect(screen.getByTestId('service-1')).toBeDefined()
|
|
141
|
+
expect(screen.getByTestId('service-2')).toBeDefined()
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// Each scope should have its own instance
|
|
145
|
+
const service1Id = screen.getByTestId('service-1').textContent
|
|
146
|
+
const service2Id = screen.getByTestId('service-2').textContent
|
|
147
|
+
|
|
148
|
+
expect(service1Id).not.toBe(service2Id)
|
|
149
|
+
expect(instanceCount).toBe(2)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
// This test is skipped because the DI package doesn't currently support
|
|
153
|
+
// concurrent access to request-scoped services from multiple components.
|
|
154
|
+
// When two components mount simultaneously and request the same request-scoped
|
|
155
|
+
// service, a race condition occurs in the DI's instance resolution.
|
|
156
|
+
// TODO: Fix in @navios/di by adding proper locking/deduplication for request-scoped services.
|
|
157
|
+
it.skip('should share instances within the same scope', async () => {
|
|
158
|
+
let instanceCount = 0
|
|
159
|
+
|
|
160
|
+
@Injectable({ registry, scope: InjectableScope.Request })
|
|
161
|
+
class RequestScopedService {
|
|
162
|
+
public readonly id: number
|
|
163
|
+
|
|
164
|
+
constructor() {
|
|
165
|
+
instanceCount++
|
|
166
|
+
this.id = instanceCount
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function ServiceDisplay({ testId }: { testId: string }) {
|
|
171
|
+
const { data, isSuccess } = useService(RequestScopedService)
|
|
172
|
+
|
|
173
|
+
if (!isSuccess) {
|
|
174
|
+
return createElement(
|
|
175
|
+
'div',
|
|
176
|
+
{ 'data-testid': `${testId}-loading` },
|
|
177
|
+
'Loading...',
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return createElement('div', { 'data-testid': testId }, String(data!.id))
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
render(
|
|
185
|
+
createWrapper(
|
|
186
|
+
createElement(
|
|
187
|
+
ScopeProvider,
|
|
188
|
+
// @ts-expect-error - props are not typed
|
|
189
|
+
{ scopeId: 'shared-scope' },
|
|
190
|
+
createElement('div', null, [
|
|
191
|
+
createElement(ServiceDisplay, { key: '1', testId: 'service-a' }),
|
|
192
|
+
createElement(ServiceDisplay, { key: '2', testId: 'service-b' }),
|
|
193
|
+
]),
|
|
194
|
+
),
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
await waitFor(() => {
|
|
199
|
+
expect(screen.getByTestId('service-a')).toBeDefined()
|
|
200
|
+
expect(screen.getByTestId('service-b')).toBeDefined()
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
// Both components in the same scope should share the instance
|
|
204
|
+
const serviceAId = screen.getByTestId('service-a').textContent
|
|
205
|
+
const serviceBId = screen.getByTestId('service-b').textContent
|
|
206
|
+
|
|
207
|
+
expect(serviceAId).toBe(serviceBId)
|
|
208
|
+
expect(instanceCount).toBe(1)
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
describe('nested scopes', () => {
|
|
213
|
+
it('should support nested scope providers', () => {
|
|
214
|
+
let outerScope: string | null = null
|
|
215
|
+
let innerScope: string | null = null
|
|
216
|
+
|
|
217
|
+
function OuterComponent() {
|
|
218
|
+
outerScope = useScope()
|
|
219
|
+
return createElement(
|
|
220
|
+
ScopeProvider,
|
|
221
|
+
// @ts-expect-error - props are not typed
|
|
222
|
+
{ scopeId: 'inner-scope' },
|
|
223
|
+
createElement(InnerComponent),
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function InnerComponent() {
|
|
228
|
+
innerScope = useScope()
|
|
229
|
+
return createElement(
|
|
230
|
+
'div',
|
|
231
|
+
{ 'data-testid': 'inner' },
|
|
232
|
+
innerScope ?? 'no-scope',
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
render(
|
|
237
|
+
createWrapper(
|
|
238
|
+
createElement(
|
|
239
|
+
ScopeProvider,
|
|
240
|
+
// @ts-expect-error - props are not typed
|
|
241
|
+
{ scopeId: 'outer-scope' },
|
|
242
|
+
createElement(OuterComponent),
|
|
243
|
+
),
|
|
244
|
+
),
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
expect(outerScope).toBe('outer-scope')
|
|
248
|
+
expect(innerScope).toBe('inner-scope')
|
|
249
|
+
})
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
describe('with metadata', () => {
|
|
253
|
+
it('should accept metadata prop', () => {
|
|
254
|
+
let scopeValue: string | null = null
|
|
255
|
+
|
|
256
|
+
function TestComponent() {
|
|
257
|
+
scopeValue = useScope()
|
|
258
|
+
return createElement('div', { 'data-testid': 'test' }, 'Test')
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Just verify it doesn't throw with metadata
|
|
262
|
+
render(
|
|
263
|
+
createWrapper(
|
|
264
|
+
createElement(
|
|
265
|
+
ScopeProvider,
|
|
266
|
+
// @ts-expect-error - props are not typed
|
|
267
|
+
{
|
|
268
|
+
scopeId: 'test-scope',
|
|
269
|
+
metadata: { userId: '123', role: 'admin' },
|
|
270
|
+
priority: 200,
|
|
271
|
+
},
|
|
272
|
+
createElement(TestComponent),
|
|
273
|
+
),
|
|
274
|
+
),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
expect(scopeValue).toBe('test-scope')
|
|
278
|
+
})
|
|
279
|
+
})
|
|
280
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Container } from '@navios/di'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
|
|
4
|
+
import { createElement } from 'react'
|
|
5
|
+
|
|
6
|
+
import { ContainerContext } from './context.mjs'
|
|
7
|
+
|
|
8
|
+
export interface ContainerProviderProps {
|
|
9
|
+
container: Container
|
|
10
|
+
children: ReactNode
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ContainerProvider({
|
|
14
|
+
container,
|
|
15
|
+
children,
|
|
16
|
+
}: ContainerProviderProps) {
|
|
17
|
+
return createElement(
|
|
18
|
+
ContainerContext.Provider,
|
|
19
|
+
{ value: container },
|
|
20
|
+
children,
|
|
21
|
+
)
|
|
22
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { ContainerContext } from './context.mjs'
|
|
2
|
+
export { ContainerProvider } from './container-provider.mjs'
|
|
3
|
+
export type { ContainerProviderProps } from './container-provider.mjs'
|
|
4
|
+
export { ScopeContext, ScopeProvider } from './scope-provider.mjs'
|
|
5
|
+
export type { ScopeProviderProps } from './scope-provider.mjs'
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import { createContext, createElement, useEffect, useId, useRef } from 'react'
|
|
4
|
+
|
|
5
|
+
import { useContainer } from '../hooks/use-container.mjs'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Context for the current scope ID.
|
|
9
|
+
* This allows nested components to access the current request scope.
|
|
10
|
+
*/
|
|
11
|
+
export const ScopeContext = createContext<string | null>(null)
|
|
12
|
+
|
|
13
|
+
export interface ScopeProviderProps {
|
|
14
|
+
/**
|
|
15
|
+
* Optional explicit scope ID. If not provided, a unique ID will be generated.
|
|
16
|
+
* Useful when you need to reference the scope externally.
|
|
17
|
+
*/
|
|
18
|
+
scopeId?: string
|
|
19
|
+
/**
|
|
20
|
+
* Optional metadata to attach to the request context.
|
|
21
|
+
* Can be used to pass data like user info, request headers, etc.
|
|
22
|
+
*/
|
|
23
|
+
metadata?: Record<string, unknown>
|
|
24
|
+
/**
|
|
25
|
+
* Priority for service resolution. Higher priority scopes take precedence.
|
|
26
|
+
* @default 100
|
|
27
|
+
*/
|
|
28
|
+
priority?: number
|
|
29
|
+
children: ReactNode
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* ScopeProvider creates a new request scope for dependency injection.
|
|
34
|
+
*
|
|
35
|
+
* Services with `scope: 'Request'` will be instantiated once per ScopeProvider
|
|
36
|
+
* and shared among all components within that provider.
|
|
37
|
+
*
|
|
38
|
+
* This is useful for:
|
|
39
|
+
* - Table rows that need isolated state
|
|
40
|
+
* - Modal dialogs with their own service instances
|
|
41
|
+
* - Multi-tenant scenarios
|
|
42
|
+
* - Any case where you need isolated service instances
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```tsx
|
|
46
|
+
* // Each row gets its own RowStateService instance
|
|
47
|
+
* {rows.map(row => (
|
|
48
|
+
* <ScopeProvider key={row.id} scopeId={row.id}>
|
|
49
|
+
* <TableRow data={row} />
|
|
50
|
+
* </ScopeProvider>
|
|
51
|
+
* ))}
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
export function ScopeProvider({
|
|
55
|
+
scopeId,
|
|
56
|
+
metadata,
|
|
57
|
+
priority = 100,
|
|
58
|
+
children,
|
|
59
|
+
}: ScopeProviderProps) {
|
|
60
|
+
const container = useContainer()
|
|
61
|
+
const generatedId = useId()
|
|
62
|
+
const effectiveScopeId = scopeId ?? generatedId
|
|
63
|
+
const isInitializedRef = useRef(false)
|
|
64
|
+
|
|
65
|
+
// Begin request context on first render only
|
|
66
|
+
// We use a ref to track initialization to handle React StrictMode double-renders
|
|
67
|
+
if (!isInitializedRef.current) {
|
|
68
|
+
// Check if context already exists (e.g., from StrictMode double render)
|
|
69
|
+
const existingContexts = container.getServiceLocator().getRequestContexts()
|
|
70
|
+
if (!existingContexts.has(effectiveScopeId)) {
|
|
71
|
+
container.beginRequest(effectiveScopeId, metadata, priority)
|
|
72
|
+
}
|
|
73
|
+
isInitializedRef.current = true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// End request context on unmount
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
return () => {
|
|
79
|
+
void container.endRequest(effectiveScopeId)
|
|
80
|
+
}
|
|
81
|
+
}, [container, effectiveScopeId])
|
|
82
|
+
|
|
83
|
+
return createElement(
|
|
84
|
+
ScopeContext.Provider,
|
|
85
|
+
{ value: effectiveScopeId },
|
|
86
|
+
children,
|
|
87
|
+
)
|
|
88
|
+
}
|
package/src/types.mts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Utility types for type-safe error messages
|
|
2
|
+
|
|
3
|
+
export type UnionToArray<T> = UnionToArrayImpl<T, never>
|
|
4
|
+
type UnionToArrayImpl<T, L extends any[]> = T extends any
|
|
5
|
+
? UnionToArrayImpl<Exclude<T, T>, [...L, T]>
|
|
6
|
+
: L
|
|
7
|
+
|
|
8
|
+
export type Join<T extends any[], Delimiter extends string> = T extends []
|
|
9
|
+
? ''
|
|
10
|
+
: T extends [infer First extends string | number | symbol]
|
|
11
|
+
? First extends string | number
|
|
12
|
+
? `${First}`
|
|
13
|
+
: never
|
|
14
|
+
: T extends [
|
|
15
|
+
infer First extends string | number | symbol,
|
|
16
|
+
...infer Rest extends any[],
|
|
17
|
+
]
|
|
18
|
+
? First extends string | number
|
|
19
|
+
? `${First}${Delimiter}${Join<Rest, Delimiter>}`
|
|
20
|
+
: never
|
|
21
|
+
: ''
|
package/tsconfig.json
ADDED
package/tsup.config.mts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { defineConfig } from 'tsup'
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
entry: ['src/index.mts'],
|
|
5
|
+
outDir: 'lib',
|
|
6
|
+
format: ['esm', 'cjs'],
|
|
7
|
+
clean: true,
|
|
8
|
+
treeshake: 'smallest',
|
|
9
|
+
sourcemap: true,
|
|
10
|
+
platform: 'browser',
|
|
11
|
+
experimentalDts: true,
|
|
12
|
+
external: ['react', '@navios/di'],
|
|
13
|
+
})
|