13
13
// We must avoid a circular dependency with @react -aria/utils, and this useLayoutEffect is
14
14
// guarded by a check that it only runs on the client side.
15
15
// eslint-disable-next-line rulesdir/useLayoutEffectRule
16
- import React , { ReactNode , useContext , useLayoutEffect , useMemo , useState } from 'react' ;
16
+ import React , { ReactNode , useContext , useLayoutEffect , useMemo , useRef , useState } from 'react' ;
17
17
18
18
// To support SSR, the auto incrementing id counter is stored in a context. This allows
19
19
// it to be reset on every request to ensure the client and server are consistent.
@@ -49,12 +49,13 @@ export interface SSRProviderProps {
49
49
*/
50
50
export function SSRProvider ( props : SSRProviderProps ) : JSX . Element {
51
51
let cur = useContext ( SSRContext ) ;
52
+ let counter = useCounter ( cur === defaultContext ) ;
52
53
let value : SSRContextValue = useMemo ( ( ) => ( {
53
54
// If this is the first SSRProvider, start with an empty string prefix, otherwise
54
55
// append and increment the counter.
55
- prefix : cur === defaultContext ? '' : `${ cur . prefix } -${ ++ cur . current } ` ,
56
+ prefix : cur === defaultContext ? '' : `${ cur . prefix } -${ counter } ` ,
56
57
current : 0
57
- } ) , [ cur ] ) ;
58
+ } ) , [ cur , counter ] ) ;
58
59
59
60
return (
60
61
< SSRContext . Provider value = { value } >
@@ -69,6 +70,46 @@ let canUseDOM = Boolean(
69
70
window . document . createElement
70
71
) ;
71
72
73
+ let componentIds = new WeakMap ( ) ;
74
+
75
+ function useCounter ( isDisabled = false ) {
76
+ let ctx = useContext ( SSRContext ) ;
77
+ let ref = useRef < number | null > ( null ) ;
78
+ if ( ref . current === null && ! isDisabled ) {
79
+ // In strict mode, React renders components twice, and the ref will be reset to null on the second render.
80
+ // This means our id counter will be incremented twice instead of once. This is a problem because on the
81
+ // server, components are only rendered once and so ids generated on the server won't match the client.
82
+ // In React 18, useId was introduced to solve this, but it is not available in older versions. So to solve this
83
+ // we need to use some React internals to access the underlying Fiber instance, which is stable between renders.
84
+ // This is exposed as ReactCurrentOwner in development, which is all we need since StrictMode only runs in development.
85
+ // To ensure that we only increment the global counter once, we store the starting id for this component in
86
+ // a weak map associated with the Fiber. On the second render, we reset the global counter to this value.
87
+ // Since React runs the second render immediately after the first, this is safe.
88
+ // @ts -ignore
89
+ let currentOwner = React . __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED ?. ReactCurrentOwner ?. current ;
90
+ if ( currentOwner ) {
91
+ let prevComponentValue = componentIds . get ( currentOwner ) ;
92
+ if ( prevComponentValue == null ) {
93
+ // On the first render, and first call to useId, store the id and state in our weak map.
94
+ componentIds . set ( currentOwner , {
95
+ id : ctx . current ,
96
+ state : currentOwner . memoizedState
97
+ } ) ;
98
+ } else if ( currentOwner . memoizedState !== prevComponentValue . state ) {
99
+ // On the second render, the memoizedState gets reset by React.
100
+ // Reset the counter, and remove from the weak map so we don't
101
+ // do this for subsequent useId calls.
102
+ ctx . current = prevComponentValue . id ;
103
+ componentIds . delete ( currentOwner ) ;
104
+ }
105
+ }
106
+
107
+ ref . current = ++ ctx . current ;
108
+ }
109
+
110
+ return ref . current ;
111
+ }
112
+
72
113
/** @private */
73
114
export function useSSRSafeId ( defaultId ?: string ) : string {
74
115
let ctx = useContext ( SSRContext ) ;
@@ -79,8 +120,8 @@ export function useSSRSafeId(defaultId?: string): string {
79
120
console . warn ( 'When server rendering, you must wrap your application in an <SSRProvider> to ensure consistent ids are generated between the client and server.' ) ;
80
121
}
81
122
82
- // eslint-disable-next-line react-hooks/exhaustive-deps
83
- return useMemo ( ( ) => defaultId || `react-aria${ ctx . prefix } -${ ++ ctx . current } ` , [ defaultId ] ) ;
123
+ let counter = useCounter ( ! ! defaultId ) ;
124
+ return defaultId || `react-aria${ ctx . prefix } -${ counter } ` ;
84
125
}
85
126
86
127
/**
0 commit comments