@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,26 @@
1
+ import { useContext } from 'react'
2
+
3
+ import { ScopeContext } from '../providers/scope-provider.mjs'
4
+
5
+ /**
6
+ * Hook to get the current scope ID.
7
+ * Returns null if not inside a ScopeProvider.
8
+ */
9
+ export function useScope(): string | null {
10
+ return useContext(ScopeContext)
11
+ }
12
+
13
+ /**
14
+ * Hook to get the current scope ID, throwing if not inside a ScopeProvider.
15
+ * Use this when your component requires a scope to function correctly.
16
+ */
17
+ export function useScopeOrThrow(): string {
18
+ const scope = useScope()
19
+ if (scope === null) {
20
+ throw new Error(
21
+ 'useScopeOrThrow must be used within a ScopeProvider. ' +
22
+ 'Wrap your component tree with <ScopeProvider> to create a request scope.',
23
+ )
24
+ }
25
+ return scope
26
+ }
@@ -0,0 +1,201 @@
1
+ import type {
2
+ AnyInjectableType,
3
+ BoundInjectionToken,
4
+ ClassType,
5
+ Factorable,
6
+ FactoryInjectionToken,
7
+ InjectionToken,
8
+ InjectionTokenSchemaType,
9
+ } from '@navios/di'
10
+ import type { z, ZodType } from 'zod/v4'
11
+
12
+ import { useCallback, useContext, useEffect, useReducer, useRef, useState } from 'react'
13
+
14
+ import type { Join, UnionToArray } from '../types.mjs'
15
+
16
+ import { ScopeContext } from '../providers/scope-provider.mjs'
17
+ import { useContainer } from './use-container.mjs'
18
+
19
+ type ServiceState<T> =
20
+ | { status: 'idle' }
21
+ | { status: 'loading' }
22
+ | { status: 'success'; data: T }
23
+ | { status: 'error'; error: Error }
24
+
25
+ type ServiceAction<T> =
26
+ | { type: 'loading' }
27
+ | { type: 'success'; data: T }
28
+ | { type: 'error'; error: Error }
29
+ | { type: 'reset' }
30
+
31
+ function serviceReducer<T>(
32
+ state: ServiceState<T>,
33
+ action: ServiceAction<T>,
34
+ ): ServiceState<T> {
35
+ switch (action.type) {
36
+ case 'loading':
37
+ return { status: 'loading' }
38
+ case 'success':
39
+ return { status: 'success', data: action.data }
40
+ case 'error':
41
+ return { status: 'error', error: action.error }
42
+ case 'reset':
43
+ return { status: 'idle' }
44
+ default:
45
+ return state
46
+ }
47
+ }
48
+
49
+ export interface UseServiceResult<T> {
50
+ data: T | undefined
51
+ error: Error | undefined
52
+ isLoading: boolean
53
+ isSuccess: boolean
54
+ isError: boolean
55
+ refetch: () => void
56
+ }
57
+
58
+ // #1 Simple class
59
+ export function useService<T extends ClassType>(
60
+ token: T,
61
+ ): UseServiceResult<
62
+ InstanceType<T> extends Factorable<infer R> ? R : InstanceType<T>
63
+ >
64
+
65
+ // #2 Token with required Schema
66
+ export function useService<T, S extends InjectionTokenSchemaType>(
67
+ token: InjectionToken<T, S>,
68
+ args: z.input<S>,
69
+ ): UseServiceResult<T>
70
+
71
+ // #3 Token with optional Schema
72
+ export function useService<
73
+ T,
74
+ S extends InjectionTokenSchemaType,
75
+ R extends boolean,
76
+ >(
77
+ token: InjectionToken<T, S, R>,
78
+ ): R extends false
79
+ ? UseServiceResult<T>
80
+ : S extends ZodType<infer Type>
81
+ ? `Error: Your token requires args: ${Join<UnionToArray<keyof Type>, ', '>}`
82
+ : 'Error: Your token requires args'
83
+
84
+ // #4 Token with no Schema
85
+ export function useService<T>(
86
+ token: InjectionToken<T, undefined>,
87
+ ): UseServiceResult<T>
88
+
89
+ export function useService<T>(
90
+ token: BoundInjectionToken<T, any>,
91
+ ): UseServiceResult<T>
92
+
93
+ export function useService<T>(
94
+ token: FactoryInjectionToken<T, any>,
95
+ ): UseServiceResult<T>
96
+
97
+ export function useService(
98
+ token:
99
+ | ClassType
100
+ | InjectionToken<any, any>
101
+ | BoundInjectionToken<any, any>
102
+ | FactoryInjectionToken<any, any>,
103
+ args?: unknown,
104
+ ): UseServiceResult<any> {
105
+ const container = useContainer()
106
+ const serviceLocator = container.getServiceLocator()
107
+ const scopeId = useContext(ScopeContext)
108
+ const [state, dispatch] = useReducer(serviceReducer, { status: 'idle' })
109
+ const instanceNameRef = useRef<string | null>(null)
110
+ const [refetchCounter, setRefetchCounter] = useState(0)
111
+
112
+ if (process.env.NODE_ENV === 'development') {
113
+ const argsRef = useRef<unknown>(args)
114
+ useEffect(() => {
115
+ if (argsRef.current !== args) {
116
+ if (JSON.stringify(argsRef.current) === JSON.stringify(args)) {
117
+ console.log(`WARNING: useService called with args that look the same but are different instances: ${JSON.stringify(argsRef.current)} !== ${JSON.stringify(args)}!
118
+ This is likely because you are using not memoized value that is not stable.
119
+ Please use a memoized value or use a different approach to pass the args.
120
+ Example:
121
+ const args = useMemo(() => ({ userId: '123' }), [])
122
+ return useService(UserToken, args)
123
+ `)
124
+ }
125
+ argsRef.current = args
126
+ }
127
+ }, [args])
128
+ }
129
+
130
+ // Subscribe to invalidation events
131
+ useEffect(() => {
132
+ const eventBus = serviceLocator.getEventBus()
133
+ let unsubscribe: (() => void) | undefined
134
+ let isMounted = true
135
+
136
+ // Fetch the service and set up subscription
137
+ const fetchAndSubscribe = async () => {
138
+ 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
+
148
+ const instance = await container.get(
149
+ // @ts-expect-error - token is valid
150
+ token as AnyInjectableType,
151
+ args as any,
152
+ )
153
+
154
+ if (!isMounted) return
155
+
156
+ // Get instance name for event subscription
157
+ const instanceName = serviceLocator.getInstanceIdentifier(
158
+ token as AnyInjectableType,
159
+ args,
160
+ )
161
+ instanceNameRef.current = instanceName
162
+
163
+ dispatch({ type: 'success', data: instance })
164
+
165
+ // Set up subscription after we have the instance
166
+ unsubscribe = eventBus.on(instanceName, 'destroy', () => {
167
+ // Re-fetch when the service is invalidated
168
+ if (isMounted) {
169
+ dispatch({ type: 'loading' })
170
+ void fetchAndSubscribe()
171
+ }
172
+ })
173
+ } catch (error) {
174
+ if (isMounted) {
175
+ dispatch({ type: 'error', error: error as Error })
176
+ }
177
+ }
178
+ }
179
+
180
+ dispatch({ type: 'loading' })
181
+ void fetchAndSubscribe()
182
+
183
+ return () => {
184
+ isMounted = false
185
+ unsubscribe?.()
186
+ }
187
+ }, [container, serviceLocator, token, args, scopeId, refetchCounter])
188
+
189
+ const refetch = useCallback(() => {
190
+ setRefetchCounter((c) => c + 1)
191
+ }, [])
192
+
193
+ return {
194
+ data: state.status === 'success' ? state.data : undefined,
195
+ error: state.status === 'error' ? state.error : undefined,
196
+ isLoading: state.status === 'loading',
197
+ isSuccess: state.status === 'success',
198
+ isError: state.status === 'error',
199
+ refetch,
200
+ }
201
+ }
@@ -0,0 +1,222 @@
1
+ import type {
2
+ AnyInjectableType,
3
+ BoundInjectionToken,
4
+ ClassType,
5
+ Factorable,
6
+ FactoryInjectionToken,
7
+ InjectionToken,
8
+ InjectionTokenSchemaType,
9
+ } from '@navios/di'
10
+ import type { z, ZodType } from 'zod/v4'
11
+
12
+ import { useCallback, useEffect, useRef, useSyncExternalStore } from 'react'
13
+
14
+ import type { Join, UnionToArray } from '../types.mjs'
15
+
16
+ import { useContainer } from './use-container.mjs'
17
+
18
+ // Cache entry for suspense
19
+ interface CacheEntry<T> {
20
+ promise: Promise<T> | null
21
+ result: T | undefined
22
+ error: Error | undefined
23
+ status: 'pending' | 'resolved' | 'rejected'
24
+ version: number // Increment on each fetch to track changes
25
+ subscribers: Set<() => void>
26
+ instanceName: string | null
27
+ unsubscribe: (() => void) | undefined
28
+ }
29
+
30
+ // Global cache for service instances (per container + token + args combination)
31
+ const cacheMap = new WeakMap<object, Map<string, CacheEntry<any>>>()
32
+
33
+ function getCacheKey(token: any, args: unknown): string {
34
+ const tokenId =
35
+ typeof token === 'function'
36
+ ? token.name
37
+ : token.id || token.token?.id || String(token)
38
+ return `${tokenId}:${JSON.stringify(args ?? null)}`
39
+ }
40
+
41
+ function getCache(container: object): Map<string, CacheEntry<any>> {
42
+ let cache = cacheMap.get(container)
43
+ if (!cache) {
44
+ cache = new Map()
45
+ cacheMap.set(container, cache)
46
+ }
47
+ return cache
48
+ }
49
+
50
+ // #1 Simple class
51
+ export function useSuspenseService<T extends ClassType>(
52
+ token: T,
53
+ ): InstanceType<T> extends Factorable<infer R> ? R : InstanceType<T>
54
+
55
+ // #2 Token with required Schema
56
+ export function useSuspenseService<T, S extends InjectionTokenSchemaType>(
57
+ token: InjectionToken<T, S>,
58
+ args: z.input<S>,
59
+ ): T
60
+
61
+ // #3 Token with optional Schema
62
+ export function useSuspenseService<
63
+ T,
64
+ S extends InjectionTokenSchemaType,
65
+ R extends boolean,
66
+ >(
67
+ token: InjectionToken<T, S, R>,
68
+ ): R extends false
69
+ ? T
70
+ : S extends ZodType<infer Type>
71
+ ? `Error: Your token requires args: ${Join<UnionToArray<keyof Type>, ', '>}`
72
+ : 'Error: Your token requires args'
73
+
74
+ // #4 Token with no Schema
75
+ export function useSuspenseService<T>(token: InjectionToken<T, undefined>): T
76
+
77
+ export function useSuspenseService<T>(token: BoundInjectionToken<T, any>): T
78
+
79
+ export function useSuspenseService<T>(token: FactoryInjectionToken<T, any>): T
80
+
81
+ export function useSuspenseService(
82
+ token:
83
+ | ClassType
84
+ | InjectionToken<any, any>
85
+ | BoundInjectionToken<any, any>
86
+ | FactoryInjectionToken<any, any>,
87
+ args?: unknown,
88
+ ): any {
89
+ const container = useContainer()
90
+ const serviceLocator = container.getServiceLocator()
91
+ const cache = getCache(container)
92
+ const cacheKey = getCacheKey(token, args)
93
+ const entryRef = useRef<CacheEntry<any> | null>(null)
94
+ if (process.env.NODE_ENV === 'development') {
95
+ const argsRef = useRef<unknown>(args)
96
+ useEffect(() => {
97
+ if (argsRef.current !== args) {
98
+ if (JSON.stringify(argsRef.current) === JSON.stringify(args)) {
99
+ console.log(`WARNING: useService called with args that look the same but are different instances: ${JSON.stringify(argsRef.current)} !== ${JSON.stringify(args)}!
100
+ This is likely because you are using not memoized value that is not stable.
101
+ Please use a memoized value or use a different approach to pass the args.
102
+ Example:
103
+ const args = useMemo(() => ({ userId: '123' }), [])
104
+ return useService(UserToken, args)
105
+ `)
106
+ }
107
+ argsRef.current = args
108
+ }
109
+ }, [args])
110
+ }
111
+
112
+ // Initialize or get cache entry
113
+ if (!cache.has(cacheKey)) {
114
+ const entry: CacheEntry<any> = {
115
+ promise: null,
116
+ result: undefined,
117
+ error: undefined,
118
+ status: 'pending',
119
+ version: 0,
120
+ subscribers: new Set(),
121
+ instanceName: null,
122
+ unsubscribe: undefined,
123
+ }
124
+ cache.set(cacheKey, entry)
125
+ }
126
+
127
+ const entry = cache.get(cacheKey)!
128
+ entryRef.current = entry
129
+
130
+ // Function to fetch the service
131
+ const fetchService = useCallback(() => {
132
+ const currentEntry = entryRef.current
133
+ if (!currentEntry) return
134
+
135
+ currentEntry.status = 'pending'
136
+ currentEntry.version++ // Increment version to signal change to useSyncExternalStore
137
+ currentEntry.promise = (container.get as any)(token, args)
138
+ .then((instance: any) => {
139
+ currentEntry.result = instance
140
+ currentEntry.status = 'resolved'
141
+ currentEntry.instanceName = serviceLocator.getInstanceIdentifier(
142
+ token as AnyInjectableType,
143
+ args,
144
+ )
145
+
146
+ // 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
+ }
163
+
164
+ // Notify subscribers
165
+ currentEntry.subscribers.forEach((callback) => callback())
166
+ return instance
167
+ })
168
+ .catch((error: Error) => {
169
+ currentEntry.error = error
170
+ currentEntry.status = 'rejected'
171
+ throw error
172
+ })
173
+
174
+ return currentEntry.promise
175
+ }, [container, serviceLocator, token, args])
176
+
177
+ // Subscribe to cache changes
178
+ const subscribe = useCallback(
179
+ (callback: () => void) => {
180
+ entry.subscribers.add(callback)
181
+ return () => {
182
+ entry.subscribers.delete(callback)
183
+ }
184
+ },
185
+ [entry],
186
+ )
187
+
188
+ // Get snapshot of current state - include version to detect invalidation
189
+ const getSnapshot = useCallback(() => {
190
+ return `${entry.status}:${entry.version}`
191
+ }, [entry])
192
+
193
+ // Use sync external store to track cache state
194
+ useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
195
+
196
+ // Cleanup subscription on unmount
197
+ 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
+ }
204
+ }
205
+ }, [entry])
206
+
207
+ // Start fetching if not already
208
+ if (entry.status === 'pending' && !entry.promise) {
209
+ fetchService()
210
+ }
211
+
212
+ // Suspense behavior
213
+ if (entry.status === 'pending') {
214
+ throw entry.promise
215
+ }
216
+
217
+ if (entry.status === 'rejected') {
218
+ throw entry.error
219
+ }
220
+
221
+ return entry.result
222
+ }
package/src/index.mts ADDED
@@ -0,0 +1,8 @@
1
+ // Providers
2
+ export * from './providers/index.mjs'
3
+
4
+ // Hooks
5
+ export * from './hooks/index.mjs'
6
+
7
+ // Types
8
+ export * from './types.mjs'