@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,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
|
+
}
|