@navios/di-react 0.1.1 → 0.2.1

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.
@@ -9,12 +9,11 @@ import type {
9
9
  } from '@navios/di'
10
10
  import type { z, ZodType } from 'zod/v4'
11
11
 
12
- import { useCallback, useContext, useEffect, useReducer, useRef, useState } from 'react'
12
+ import { useCallback, useEffect, useReducer, useRef, useState } from 'react'
13
13
 
14
14
  import type { Join, UnionToArray } from '../types.mjs'
15
15
 
16
- import { ScopeContext } from '../providers/scope-provider.mjs'
17
- import { useContainer } from './use-container.mjs'
16
+ import { useContainer, useRootContainer } from './use-container.mjs'
18
17
 
19
18
  type ServiceState<T> =
20
19
  | { status: 'idle' }
@@ -102,10 +101,28 @@ export function useService(
102
101
  | FactoryInjectionToken<any, any>,
103
102
  args?: unknown,
104
103
  ): UseServiceResult<any> {
104
+ // useContainer returns ScopedContainer if inside ScopeProvider, otherwise Container
105
+ // This automatically handles request-scoped services correctly
105
106
  const container = useContainer()
106
- const serviceLocator = container.getServiceLocator()
107
- const scopeId = useContext(ScopeContext)
108
- const [state, dispatch] = useReducer(serviceReducer, { status: 'idle' })
107
+ const rootContainer = useRootContainer()
108
+ const serviceLocator = rootContainer.getServiceLocator()
109
+
110
+ // Try to get the instance synchronously first for better performance
111
+ // This avoids the async loading state when the instance is already cached
112
+ // We use a ref to track this so it doesn't cause effect re-runs
113
+ const initialSyncInstanceRef = useRef<any>(undefined)
114
+ const isFirstRenderRef = useRef(true)
115
+
116
+ if (isFirstRenderRef.current) {
117
+ initialSyncInstanceRef.current = container.tryGetSync(token, args)
118
+ isFirstRenderRef.current = false
119
+ }
120
+
121
+ const initialState: ServiceState<any> = initialSyncInstanceRef.current
122
+ ? { status: 'success', data: initialSyncInstanceRef.current }
123
+ : { status: 'idle' }
124
+
125
+ const [state, dispatch] = useReducer(serviceReducer, initialState)
109
126
  const instanceNameRef = useRef<string | null>(null)
110
127
  const [refetchCounter, setRefetchCounter] = useState(0)
111
128
 
@@ -136,15 +153,7 @@ export function useService(
136
153
  // Fetch the service and set up subscription
137
154
  const fetchAndSubscribe = async () => {
138
155
  try {
139
- // Set the correct request context before getting the instance
140
- // This ensures request-scoped services are resolved in the correct scope
141
- if (scopeId) {
142
- const requestContexts = serviceLocator.getRequestContexts()
143
- if (requestContexts.has(scopeId)) {
144
- container.setCurrentRequestContext(scopeId)
145
- }
146
- }
147
-
156
+ // The container (either ScopedContainer or Container) handles resolution correctly
148
157
  const instance = await container.get(
149
158
  // @ts-expect-error - token is valid
150
159
  token as AnyInjectableType,
@@ -177,14 +186,31 @@ export function useService(
177
186
  }
178
187
  }
179
188
 
180
- dispatch({ type: 'loading' })
181
- void fetchAndSubscribe()
189
+ // If we already have a sync instance from initial render, just set up subscription
190
+ // Otherwise, fetch async
191
+ const syncInstance = initialSyncInstanceRef.current
192
+ if (syncInstance && refetchCounter === 0) {
193
+ const instanceName = serviceLocator.getInstanceIdentifier(
194
+ token as AnyInjectableType,
195
+ args,
196
+ )
197
+ instanceNameRef.current = instanceName
198
+ unsubscribe = eventBus.on(instanceName, 'destroy', () => {
199
+ if (isMounted) {
200
+ dispatch({ type: 'loading' })
201
+ void fetchAndSubscribe()
202
+ }
203
+ })
204
+ } else {
205
+ dispatch({ type: 'loading' })
206
+ void fetchAndSubscribe()
207
+ }
182
208
 
183
209
  return () => {
184
210
  isMounted = false
185
211
  unsubscribe?.()
186
212
  }
187
- }, [container, serviceLocator, token, args, scopeId, refetchCounter])
213
+ }, [container, serviceLocator, token, args, refetchCounter])
188
214
 
189
215
  const refetch = useCallback(() => {
190
216
  setRefetchCounter((c) => c + 1)
@@ -13,7 +13,7 @@ import { useCallback, useEffect, useRef, useSyncExternalStore } from 'react'
13
13
 
14
14
  import type { Join, UnionToArray } from '../types.mjs'
15
15
 
16
- import { useContainer } from './use-container.mjs'
16
+ import { useContainer, useRootContainer } from './use-container.mjs'
17
17
 
18
18
  // Cache entry for suspense
19
19
  interface CacheEntry<T> {
@@ -47,6 +47,30 @@ function getCache(container: object): Map<string, CacheEntry<any>> {
47
47
  return cache
48
48
  }
49
49
 
50
+ /**
51
+ * Sets up invalidation subscription for a cache entry if not already subscribed.
52
+ * When the service is destroyed, clears the cache and notifies subscribers.
53
+ */
54
+ function setupInvalidationSubscription(
55
+ entry: CacheEntry<any>,
56
+ serviceLocator: ReturnType<
57
+ import('@navios/di').Container['getServiceLocator']
58
+ >,
59
+ ): void {
60
+ if (entry.unsubscribe || !entry.instanceName) return
61
+
62
+ const eventBus = serviceLocator.getEventBus()
63
+ entry.unsubscribe = eventBus.on(entry.instanceName, 'destroy', () => {
64
+ // Clear cache and notify subscribers to re-fetch
65
+ entry.result = undefined
66
+ entry.error = undefined
67
+ entry.status = 'pending'
68
+ entry.promise = null
69
+ // Notify all subscribers
70
+ entry.subscribers.forEach((callback) => callback())
71
+ })
72
+ }
73
+
50
74
  // #1 Simple class
51
75
  export function useSuspenseService<T extends ClassType>(
52
76
  token: T,
@@ -86,8 +110,10 @@ export function useSuspenseService(
86
110
  | FactoryInjectionToken<any, any>,
87
111
  args?: unknown,
88
112
  ): any {
113
+ // useContainer returns ScopedContainer if inside ScopeProvider, otherwise Container
89
114
  const container = useContainer()
90
- const serviceLocator = container.getServiceLocator()
115
+ const rootContainer = useRootContainer()
116
+ const serviceLocator = rootContainer.getServiceLocator()
91
117
  const cache = getCache(container)
92
118
  const cacheKey = getCacheKey(token, args)
93
119
  const entryRef = useRef<CacheEntry<any> | null>(null)
@@ -111,14 +137,20 @@ export function useSuspenseService(
111
137
 
112
138
  // Initialize or get cache entry
113
139
  if (!cache.has(cacheKey)) {
140
+ // Try to get the instance synchronously first for better performance
141
+ // This avoids suspense when the instance is already cached
142
+ const syncInstance = container.tryGetSync(token, args)
143
+
114
144
  const entry: CacheEntry<any> = {
115
145
  promise: null,
116
- result: undefined,
146
+ result: syncInstance ?? undefined,
117
147
  error: undefined,
118
- status: 'pending',
148
+ status: syncInstance ? 'resolved' : 'pending',
119
149
  version: 0,
120
150
  subscribers: new Set(),
121
- instanceName: null,
151
+ instanceName: syncInstance
152
+ ? serviceLocator.getInstanceIdentifier(token as AnyInjectableType, args)
153
+ : null,
122
154
  unsubscribe: undefined,
123
155
  }
124
156
  cache.set(cacheKey, entry)
@@ -144,22 +176,7 @@ export function useSuspenseService(
144
176
  )
145
177
 
146
178
  // Subscribe to invalidation events if not already subscribed
147
- if (!currentEntry.unsubscribe && currentEntry.instanceName) {
148
- const eventBus = serviceLocator.getEventBus()
149
- currentEntry.unsubscribe = eventBus.on(
150
- currentEntry.instanceName,
151
- 'destroy',
152
- () => {
153
- // Clear cache and notify subscribers to re-fetch
154
- currentEntry.result = undefined
155
- currentEntry.error = undefined
156
- currentEntry.status = 'pending'
157
- currentEntry.promise = null
158
- // Notify all subscribers
159
- currentEntry.subscribers.forEach((callback) => callback())
160
- },
161
- )
162
- }
179
+ setupInvalidationSubscription(currentEntry, serviceLocator)
163
180
 
164
181
  // Notify subscribers
165
182
  currentEntry.subscribers.forEach((callback) => callback())
@@ -193,16 +210,18 @@ export function useSuspenseService(
193
210
  // Use sync external store to track cache state
194
211
  useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
195
212
 
196
- // Cleanup subscription on unmount
213
+ // Set up subscription for sync instances that don't have one yet
197
214
  useEffect(() => {
198
- return () => {
199
- // // If there are no subscribers, unsubscribe and delete the cache entry
200
- // if (entry.subscribers.size === 0) {
201
- // entry.unsubscribe?.()
202
- // cache.delete(cacheKey)
203
- // }
215
+ const currentEntry = entryRef.current
216
+ if (
217
+ currentEntry &&
218
+ currentEntry.status === 'resolved' &&
219
+ currentEntry.instanceName &&
220
+ !currentEntry.unsubscribe
221
+ ) {
222
+ setupInvalidationSubscription(currentEntry, serviceLocator)
204
223
  }
205
- }, [])
224
+ }, [serviceLocator, entry])
206
225
 
207
226
  // Start fetching if not already
208
227
  if (entry.status === 'pending' && !entry.promise) {
@@ -5,7 +5,7 @@ import { createElement } from 'react'
5
5
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6
6
 
7
7
  import { useService } from '../../hooks/use-service.mjs'
8
- import { useScope } from '../../hooks/use-scope.mjs'
8
+ import { useScope, useScopeMetadata, useScopedContainer } from '../../hooks/use-scope.mjs'
9
9
  import { ContainerProvider } from '../container-provider.mjs'
10
10
  import { ScopeProvider } from '../scope-provider.mjs'
11
11
 
@@ -276,5 +276,88 @@ describe('ScopeProvider', () => {
276
276
 
277
277
  expect(scopeValue).toBe('test-scope')
278
278
  })
279
+
280
+ it('should provide metadata via useScopeMetadata', () => {
281
+ let userId: string | undefined
282
+ let role: string | undefined
283
+ let missing: string | undefined
284
+
285
+ function TestComponent() {
286
+ userId = useScopeMetadata<string>('userId')
287
+ role = useScopeMetadata<string>('role')
288
+ missing = useScopeMetadata<string>('nonexistent')
289
+ return createElement('div', { 'data-testid': 'test' }, 'Test')
290
+ }
291
+
292
+ render(
293
+ createWrapper(
294
+ createElement(
295
+ ScopeProvider,
296
+ // @ts-expect-error - props are not typed
297
+ {
298
+ scopeId: 'test-scope',
299
+ metadata: { userId: '123', role: 'admin' },
300
+ },
301
+ createElement(TestComponent),
302
+ ),
303
+ ),
304
+ )
305
+
306
+ expect(userId).toBe('123')
307
+ expect(role).toBe('admin')
308
+ expect(missing).toBeUndefined()
309
+ })
310
+
311
+ it('should return undefined for useScopeMetadata outside ScopeProvider', () => {
312
+ let userId: string | undefined = 'initial'
313
+
314
+ function TestComponent() {
315
+ userId = useScopeMetadata<string>('userId')
316
+ return createElement('div', { 'data-testid': 'test' }, 'Test')
317
+ }
318
+
319
+ render(createWrapper(createElement(TestComponent)))
320
+
321
+ expect(userId).toBeUndefined()
322
+ })
323
+ })
324
+
325
+ describe('useScopedContainer', () => {
326
+ it('should return null when not inside ScopeProvider', () => {
327
+ let scopedContainer: unknown = 'not-set'
328
+
329
+ function TestComponent() {
330
+ scopedContainer = useScopedContainer()
331
+ return createElement('div', { 'data-testid': 'test' }, 'Test')
332
+ }
333
+
334
+ render(createWrapper(createElement(TestComponent)))
335
+
336
+ expect(scopedContainer).toBeNull()
337
+ })
338
+
339
+ it('should return ScopedContainer when inside ScopeProvider', () => {
340
+ let scopedContainer: unknown = null
341
+
342
+ function TestComponent() {
343
+ scopedContainer = useScopedContainer()
344
+ return createElement('div', { 'data-testid': 'test' }, 'Test')
345
+ }
346
+
347
+ render(
348
+ createWrapper(
349
+ createElement(
350
+ ScopeProvider,
351
+ // @ts-expect-error - props are not typed
352
+ { scopeId: 'test-scope' },
353
+ createElement(TestComponent),
354
+ ),
355
+ ),
356
+ )
357
+
358
+ expect(scopedContainer).not.toBeNull()
359
+ expect(typeof (scopedContainer as any).getRequestId).toBe('function')
360
+ expect((scopedContainer as any).getRequestId()).toBe('test-scope')
361
+ })
279
362
  })
280
363
  })
@@ -1,5 +1,15 @@
1
1
  import { createContext } from 'react'
2
2
 
3
- import type { Container } from '@navios/di'
3
+ import type { Container, ScopedContainer } from '@navios/di'
4
4
 
5
+ /**
6
+ * Context for the root Container.
7
+ * This is set by ContainerProvider and provides the base container.
8
+ */
5
9
  export const ContainerContext = createContext<Container | null>(null)
10
+
11
+ /**
12
+ * Context for the current ScopedContainer (if inside a ScopeProvider).
13
+ * This allows nested components to access request-scoped services.
14
+ */
15
+ export const ScopedContainerContext = createContext<ScopedContainer | null>(null)
@@ -1,5 +1,5 @@
1
- export { ContainerContext } from './context.mjs'
1
+ export { ContainerContext, ScopedContainerContext } from './context.mjs'
2
2
  export { ContainerProvider } from './container-provider.mjs'
3
3
  export type { ContainerProviderProps } from './container-provider.mjs'
4
- export { ScopeContext, ScopeProvider } from './scope-provider.mjs'
4
+ export { ScopeProvider } from './scope-provider.mjs'
5
5
  export type { ScopeProviderProps } from './scope-provider.mjs'
@@ -1,15 +1,10 @@
1
1
  import type { ReactNode } from 'react'
2
+ import type { ScopedContainer } from '@navios/di'
2
3
 
3
- import { createContext, useEffect, useId, useRef } from 'react'
4
+ import { useContext, useEffect, useId, useRef } from 'react'
4
5
  import { jsx } from 'react/jsx-runtime'
5
6
 
6
- import { useContainer } from '../hooks/use-container.mjs'
7
-
8
- /**
9
- * Context for the current scope ID.
10
- * This allows nested components to access the current request scope.
11
- */
12
- export const ScopeContext = createContext<string | null>(null)
7
+ import { ContainerContext, ScopedContainerContext } from './context.mjs'
13
8
 
14
9
  export interface ScopeProviderProps {
15
10
  /**
@@ -46,8 +41,8 @@ export interface ScopeProviderProps {
46
41
  * ```tsx
47
42
  * // Each row gets its own RowStateService instance
48
43
  * {rows.map(row => (
49
- * <ScopeProvider key={row.id} scopeId={row.id}>
50
- * <TableRow data={row} />
44
+ * <ScopeProvider key={row.id} scopeId={row.id} metadata={{ rowData: row }}>
45
+ * <TableRow />
51
46
  * </ScopeProvider>
52
47
  * ))}
53
48
  * ```
@@ -58,28 +53,45 @@ export function ScopeProvider({
58
53
  priority = 100,
59
54
  children,
60
55
  }: ScopeProviderProps) {
61
- const container = useContainer()
56
+ const container = useContext(ContainerContext)
57
+ if (!container) {
58
+ throw new Error('ScopeProvider must be used within a ContainerProvider')
59
+ }
60
+
62
61
  const generatedId = useId()
63
62
  const effectiveScopeId = scopeId ?? generatedId
64
- const isInitializedRef = useRef(false)
63
+ const scopedContainerRef = useRef<ScopedContainer | null>(null)
65
64
 
66
- // Begin request context on first render only
65
+ // Create ScopedContainer on first render only
67
66
  // We use a ref to track initialization to handle React StrictMode double-renders
68
- if (!isInitializedRef.current) {
69
- // Check if context already exists (e.g., from StrictMode double render)
70
- const existingContexts = container.getServiceLocator().getRequestContexts()
71
- if (!existingContexts.has(effectiveScopeId)) {
72
- container.beginRequest(effectiveScopeId, metadata, priority)
67
+ if (!scopedContainerRef.current) {
68
+ // Check if this request ID already exists (e.g., from StrictMode double render)
69
+ if (!container.hasActiveRequest(effectiveScopeId)) {
70
+ scopedContainerRef.current = container.beginRequest(
71
+ effectiveScopeId,
72
+ metadata,
73
+ priority,
74
+ )
73
75
  }
74
- isInitializedRef.current = true
75
76
  }
76
77
 
77
78
  // End request context on unmount
78
79
  useEffect(() => {
80
+ const scopedContainer = scopedContainerRef.current
79
81
  return () => {
80
- void container.endRequest(effectiveScopeId)
82
+ if (scopedContainer) {
83
+ void scopedContainer.endRequest()
84
+ }
81
85
  }
82
- }, [container, effectiveScopeId])
86
+ }, [])
87
+
88
+ // If we don't have a scoped container (shouldn't happen normally), don't render
89
+ if (!scopedContainerRef.current) {
90
+ return null
91
+ }
83
92
 
84
- return jsx(ScopeContext.Provider, { value: effectiveScopeId, children })
93
+ return jsx(ScopedContainerContext.Provider, {
94
+ value: scopedContainerRef.current,
95
+ children,
96
+ })
85
97
  }
@@ -1,13 +1,14 @@
1
- import { defineConfig } from 'tsup'
1
+ import { defineConfig } from 'tsdown'
2
2
 
3
3
  export default defineConfig({
4
4
  entry: ['src/index.mts'],
5
5
  outDir: 'lib',
6
6
  format: ['esm', 'cjs'],
7
7
  clean: true,
8
- treeshake: 'smallest',
8
+ treeshake: true,
9
9
  sourcemap: true,
10
10
  platform: 'browser',
11
- experimentalDts: true,
11
+ dts: true,
12
+ target: 'es2022',
12
13
  external: ['react', '@navios/di'],
13
14
  })