@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,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
+ import { createContext } from 'react'
2
+
3
+ import type { Container } from '@navios/di'
4
+
5
+ export const ContainerContext = createContext<Container | null>(null)
@@ -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
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "module": "Node16",
5
+ "outDir": "dist"
6
+ },
7
+ "include": ["src/**/*"],
8
+ "references": [
9
+ {
10
+ "path": "../di"
11
+ }
12
+ ]
13
+ }
@@ -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
+ })
@@ -0,0 +1,9 @@
1
+ import { defineProject } from 'vitest/config'
2
+
3
+ export default defineProject({
4
+ test: {
5
+ globals: true,
6
+ environment: 'jsdom',
7
+ include: ['src/**/__tests__/**/*.spec.mts'],
8
+ },
9
+ })