Implement identifierPrefix option for useId (#22855)
When an `identifierPrefix` option is given, React will add it to the beginning of ids generated by `useId`. The main use case is to avoid conflicts when there are multiple React roots on a single page. The server API already supported an `identifierPrefix` option. It's not only used by `useId`, but also for React-generated ids that are used to stitch together chunks of HTML, among other things. I added a corresponding option to the client. You must pass the same prefix option to both the server and client. Eventually we may make this automatic by sending the prefix from the server as part of the HTML stream.
Andrew Clark committed
Dec 2, 2021 at 20:49 UTC
4729ff6d1f191902897927ff4ecd3d1f390177fa
21 files changed
+223
-36
packages/react-art/src/ReactART.js
+9
-1
@@ -66,7 +66,15 @@ class Surface extends React.Component {
66
67
this._surface = Mode.Surface(+width, +height, this._tagRef);
68
69
- this._mountNode = createContainer(this._surface, LegacyRoot, false, null);
69
+ this._mountNode = createContainer(
70
+ this._surface,
71
+ LegacyRoot,
72
+ false,
73
+ null,
74
+ false,
75
+ false,
76
+ '',
77
+ );
78
updateContainer(this.props.children, this._mountNode, this);
79
}
80
packages/react-dom/src/__tests__/ReactDOMUseId-test.js
+63
-1
@@ -94,7 +94,7 @@ describe('useId', () => {
94
95
function normalizeTreeIdForTesting(id) {
96
const [serverClientPrefix, base32, hookIndex] = id.split(':');
97
- if (serverClientPrefix === 'r') {
97
+ if (serverClientPrefix.endsWith('r')) {
98
// Client ids aren't stable. For testing purposes, strip out the counter.
99
return (
100
'CLIENT_GENERATED_ID' +
@@ -569,4 +569,66 @@ describe('useId', () => {
569
// Should have hydrated successfully
570
expect(span.current).toBe(dehydratedSpan);
571
});
572
+
573
+ test('identifierPrefix option', async () => {
574
+ function Child() {
575
+ const id = useId();
576
+ return <div>{id}</div>;
577
+ }
578
+
579
+ function App({showMore}) {
580
+ return (
581
+ <>
582
+ <Child />
583
+ <Child />
584
+ {showMore && <Child />}
585
+ </>
586
+ );
587
+ }
588
+
589
+ await serverAct(async () => {
590
+ const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
591
+ identifierPrefix: 'custom-prefix-',
592
+ });
593
+ pipe(writable);
594
+ });
595
+ let root;
596
+ await clientAct(async () => {
597
+ root = ReactDOM.hydrateRoot(container, <App />, {
598
+ identifierPrefix: 'custom-prefix-',
599
+ });
600
+ });
601
+ expect(container).toMatchInlineSnapshot(`
602
+ <div
603
+ id="container"
604
+ >
605
+ <div>
606
+ custom-prefix-R:1
607
+ </div>
608
+ <div>
609
+ custom-prefix-R:2
610
+ </div>
611
+ </div>
612
+ `);
613
+
614
+ // Mount a new, client-only id
615
+ await clientAct(async () => {
616
+ root.render(<App showMore={true} />);
617
+ });
618
+ expect(container).toMatchInlineSnapshot(`
619
+ <div
620
+ id="container"
621
+ >
622
+ <div>
623
+ custom-prefix-R:1
624
+ </div>
625
+ <div>
626
+ custom-prefix-R:2
627
+ </div>
628
+ <div>
629
+ custom-prefix-r:0
630
+ </div>
631
+ </div>
632
+ `);
633
+ });
634
});
packages/react-dom/src/client/ReactDOMLegacy.js
+1
@@ -121,6 +121,7 @@ function legacyCreateRootFromDOMContainer(
121
null, // hydrationCallbacks
122
false, // isStrictMode
123
false, // concurrentUpdatesByDefaultOverride,
124
+ '', // identiferPrefix
125
);
126
markContainerAsRoot(root.current, container);
127
packages/react-dom/src/client/ReactDOMRoot.js
+38
-15
@@ -32,6 +32,7 @@ export type CreateRootOptions = {
32
// END OF TODO
33
unstable_strictMode?: boolean,
34
unstable_concurrentUpdatesByDefault?: boolean,
35
+ identifierPrefix?: string,
36
...
37
};
38
@@ -43,6 +44,7 @@ export type HydrateRootOptions = {
44
// Options for all roots
45
unstable_strictMode?: boolean,
46
unstable_concurrentUpdatesByDefault?: boolean,
47
+ identifierPrefix?: string,
48
...
49
};
50
@@ -158,13 +160,22 @@ export function createRoot(
160
null;
161
// END TODO
162
161
- const isStrictMode = options != null && options.unstable_strictMode === true;
162
- let concurrentUpdatesByDefaultOverride = null;
163
- if (allowConcurrentByDefault) {
164
- concurrentUpdatesByDefaultOverride =
165
- options != null && options.unstable_concurrentUpdatesByDefault != null
166
- ? options.unstable_concurrentUpdatesByDefault
167
- : null;
163
+ let isStrictMode = false;
164
+ let concurrentUpdatesByDefaultOverride = false;
165
+ let identifierPrefix = '';
166
+ if (options !== null && options !== undefined) {
167
+ if (options.unstable_strictMode === true) {
168
+ isStrictMode = true;
169
+ }
170
+ if (
171
+ allowConcurrentByDefault &&
172
+ options.unstable_concurrentUpdatesByDefault === true
173
+ ) {
174
+ concurrentUpdatesByDefaultOverride = true;
175
+ }
176
+ if (options.identifierPrefix !== undefined) {
177
+ identifierPrefix = options.identifierPrefix;
178
+ }
179
}
180
181
const root = createContainer(
@@ -174,6 +185,7 @@ export function createRoot(
185
hydrationCallbacks,
186
isStrictMode,
187
concurrentUpdatesByDefaultOverride,
188
+ identifierPrefix,
189
);
190
markContainerAsRoot(root.current, container);
191
@@ -217,15 +229,25 @@ export function hydrateRoot(
229
// For now we reuse the whole bag of options since they contain
230
// the hydration callbacks.
231
const hydrationCallbacks = options != null ? options : null;
232
+ // TODO: Delete this option
233
const mutableSources = (options != null && options.hydratedSources) || null;
221
- const isStrictMode = options != null && options.unstable_strictMode === true;
222
-
223
- let concurrentUpdatesByDefaultOverride = null;
224
- if (allowConcurrentByDefault) {
225
- concurrentUpdatesByDefaultOverride =
226
- options != null && options.unstable_concurrentUpdatesByDefault != null
227
- ? options.unstable_concurrentUpdatesByDefault
228
- : null;
234
+
235
+ let isStrictMode = false;
236
+ let concurrentUpdatesByDefaultOverride = false;
237
+ let identifierPrefix = '';
238
+ if (options !== null && options !== undefined) {
239
+ if (options.unstable_strictMode === true) {
240
+ isStrictMode = true;
241
+ }
242
+ if (
243
+ allowConcurrentByDefault &&
244
+ options.unstable_concurrentUpdatesByDefault === true
245
+ ) {
246
+ concurrentUpdatesByDefaultOverride = true;
247
+ }
248
+ if (options.identifierPrefix !== undefined) {
249
+ identifierPrefix = options.identifierPrefix;
250
+ }
251
}
252
253
const root = createContainer(
@@ -235,6 +257,7 @@ export function hydrateRoot(
257
hydrationCallbacks,
258
isStrictMode,
259
concurrentUpdatesByDefaultOverride,
260
+ identifierPrefix,
261
);
262
markContainerAsRoot(root.current, container);
263
// This can't be a comment node since hydration doesn't work on comment nodes anyway.
packages/react-dom/src/server/ReactDOMServerFormatConfig.js
+21
@@ -64,6 +64,7 @@ export type ResponseState = {
64
placeholderPrefix: PrecomputedChunk,
65
segmentPrefix: PrecomputedChunk,
66
boundaryPrefix: string,
67
+ idPrefix: string,
68
nextSuspenseID: number,
69
sentCompleteSegmentFunction: boolean,
70
sentCompleteBoundaryFunction: boolean,
@@ -125,6 +126,7 @@ export function createResponseState(
126
placeholderPrefix: stringToPrecomputedChunk(idPrefix + 'P:'),
127
segmentPrefix: stringToPrecomputedChunk(idPrefix + 'S:'),
128
boundaryPrefix: idPrefix + 'B:',
129
+ idPrefix: idPrefix + 'R:',
130
nextSuspenseID: 0,
131
sentCompleteSegmentFunction: false,
132
sentCompleteBoundaryFunction: false,
@@ -229,6 +231,25 @@ export function assignSuspenseBoundaryID(
231
);
232
}
233
234
+export function makeId(
235
+ responseState: ResponseState,
236
+ treeId: string,
237
+ localId: number,
238
+): string {
239
+ const idPrefix = responseState.idPrefix;
240
+
241
+ let id = idPrefix + treeId;
242
+
243
+ // Unless this is the first id at this level, append a number at the end
244
+ // that represents the position of this useId hook among all the useId
245
+ // hooks for this fiber.
246
+ if (localId > 0) {
247
+ id += ':' + localId.toString(32);
248
+ }
249
+
250
+ return id;
251
+}
252
+
253
function encodeHTMLTextNode(text: string): string {
254
return escapeTextForBrowser(text);
255
}
packages/react-dom/src/server/ReactDOMServerLegacyFormatConfig.js
+3
@@ -34,6 +34,7 @@ export type ResponseState = {
34
placeholderPrefix: PrecomputedChunk,
35
segmentPrefix: PrecomputedChunk,
36
boundaryPrefix: string,
37
+ idPrefix: string,
38
nextSuspenseID: number,
39
sentCompleteSegmentFunction: boolean,
40
sentCompleteBoundaryFunction: boolean,
@@ -54,6 +55,7 @@ export function createResponseState(
55
placeholderPrefix: responseState.placeholderPrefix,
56
segmentPrefix: responseState.segmentPrefix,
57
boundaryPrefix: responseState.boundaryPrefix,
58
+ idPrefix: responseState.idPrefix,
59
nextSuspenseID: responseState.nextSuspenseID,
60
sentCompleteSegmentFunction: responseState.sentCompleteSegmentFunction,
61
sentCompleteBoundaryFunction: responseState.sentCompleteBoundaryFunction,
@@ -79,6 +81,7 @@ export {
81
getChildFormatContext,
82
UNINITIALIZED_SUSPENSE_BOUNDARY_ID,
83
assignSuspenseBoundaryID,
84
+ makeId,
85
pushStartInstance,
86
pushEndInstance,
87
pushStartCompletedSuspenseBoundary,
packages/react-native-renderer/src/ReactFabric.js
+1
@@ -213,6 +213,7 @@ function render(
213
null,
214
false,
215
null,
216
+ '',
217
);
218
roots.set(containerTag, root);
219
}
packages/react-native-renderer/src/ReactNativeRenderer.js
+9
-1
@@ -202,7 +202,15 @@ function render(
202
if (!root) {
203
// TODO (bvaughn): If we decide to keep the wrapper component,
204
// We could create a wrapper for containerTag as well to reduce special casing.
205
- root = createContainer(containerTag, LegacyRoot, false, null, false, null);
205
+ root = createContainer(
206
+ containerTag,
207
+ LegacyRoot,
208
+ false,
209
+ null,
210
+ false,
211
+ null,
212
+ '',
213
+ );
214
roots.set(containerTag, root);
215
}
216
updateContainer(element, root, null, callback);
packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js
+8
@@ -107,6 +107,14 @@ export function assignSuspenseBoundaryID(
107
return responseState.nextSuspenseID++;
108
}
109
110
+export function makeId(
111
+ responseState: ResponseState,
112
+ treeId: string,
113
+ localId: number,
114
+): string {
115
+ throw new Error('Not implemented');
116
+}
117
+
118
const RAW_TEXT = stringToPrecomputedChunk('RCTRawText');
119
120
export function pushTextInstance(
packages/react-noop-renderer/src/createReactNoop.js
+4
@@ -973,6 +973,8 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
973
false,
974
null,
975
null,
976
+ false,
977
+ '',
978
);
979
return {
980
_Scheduler: Scheduler,
@@ -1000,6 +1002,8 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
1002
false,
1003
null,
1004
null,
1005
+ false,
1006
+ '',
1007
);
1008
return {
1009
_Scheduler: Scheduler,
packages/react-reconciler/src/ReactFiberHooks.new.js
+10
-2
@@ -2035,12 +2035,20 @@ export function getIsUpdatingOpaqueValueInRenderPhaseInDEV(): boolean | void {
2035
function mountId(): string {
2036
const hook = mountWorkInProgressHook();
2037
2038
+ const root = ((getWorkInProgressRoot(): any): FiberRoot);
2039
+ // TODO: In Fizz, id generation is specific to each server config. Maybe we
2040
+ // should do this in Fiber, too? Deferring this decision for now because
2041
+ // there's no other place to store the prefix except for an internal field on
2042
+ // the public createRoot object, which the fiber tree does not currently have
2043
+ // a reference to.
2044
+ const identifierPrefix = root.identifierPrefix;
2045
+
2046
let id;
2047
if (getIsHydrating()) {
2048
const treeId = getTreeId();
2049
2050
// Use a captial R prefix for server-generated ids.
2043
- id = 'R:' + treeId;
2051
+ id = identifierPrefix + 'R:' + treeId;
2052
2053
// Unless this is the first id at this level, append a number at the end
2054
// that represents the position of this useId hook among all the useId
@@ -2052,7 +2060,7 @@ function mountId(): string {
2060
} else {
2061
// Use a lowercase r prefix for client-generated ids.
2062
const globalClientId = globalClientIdCounter++;
2055
- id = 'r:' + globalClientId.toString(32);
2063
+ id = identifierPrefix + 'r:' + globalClientId.toString(32);
2064
}
2065
2066
hook.memoizedState = id;
packages/react-reconciler/src/ReactFiberHooks.old.js
+10
-2
@@ -2035,12 +2035,20 @@ export function getIsUpdatingOpaqueValueInRenderPhaseInDEV(): boolean | void {
2035
function mountId(): string {
2036
const hook = mountWorkInProgressHook();
2037
2038
+ const root = ((getWorkInProgressRoot(): any): FiberRoot);
2039
+ // TODO: In Fizz, id generation is specific to each server config. Maybe we
2040
+ // should do this in Fiber, too? Deferring this decision for now because
2041
+ // there's no other place to store the prefix except for an internal field on
2042
+ // the public createRoot object, which the fiber tree does not currently have
2043
+ // a reference to.
2044
+ const identifierPrefix = root.identifierPrefix;
2045
+
2046
let id;
2047
if (getIsHydrating()) {
2048
const treeId = getTreeId();
2049
2050
// Use a captial R prefix for server-generated ids.
2043
- id = 'R:' + treeId;
2051
+ id = identifierPrefix + 'R:' + treeId;
2052
2053
// Unless this is the first id at this level, append a number at the end
2054
// that represents the position of this useId hook among all the useId
@@ -2052,7 +2060,7 @@ function mountId(): string {
2060
} else {
2061
// Use a lowercase r prefix for client-generated ids.
2062
const globalClientId = globalClientIdCounter++;
2055
- id = 'r:' + globalClientId.toString(32);
2063
+ id = identifierPrefix + 'r:' + globalClientId.toString(32);
2064
}
2065
2066
hook.memoizedState = id;
packages/react-reconciler/src/ReactFiberReconciler.new.js
+2
@@ -241,6 +241,7 @@ export function createContainer(
241
hydrationCallbacks: null | SuspenseHydrationCallbacks,
242
isStrictMode: boolean,
243
concurrentUpdatesByDefaultOverride: null | boolean,
244
+ identifierPrefix: string,
245
): OpaqueRoot {
246
return createFiberRoot(
247
containerInfo,
@@ -249,6 +250,7 @@ export function createContainer(
250
hydrationCallbacks,
251
isStrictMode,
252
concurrentUpdatesByDefaultOverride,
253
+ identifierPrefix,
254
);
255
}
256
packages/react-reconciler/src/ReactFiberReconciler.old.js
+2
@@ -241,6 +241,7 @@ export function createContainer(
241
hydrationCallbacks: null | SuspenseHydrationCallbacks,
242
isStrictMode: boolean,
243
concurrentUpdatesByDefaultOverride: null | boolean,
244
+ identifierPrefix: string,
245
): OpaqueRoot {
246
return createFiberRoot(
247
containerInfo,
@@ -249,6 +250,7 @@ export function createContainer(
250
hydrationCallbacks,
251
isStrictMode,
252
concurrentUpdatesByDefaultOverride,
253
+ identifierPrefix,
254
);
255
}
256
packages/react-reconciler/src/ReactFiberRoot.new.js
+10
-2
@@ -30,7 +30,7 @@ import {initializeUpdateQueue} from './ReactUpdateQueue.new';
30
import {LegacyRoot, ConcurrentRoot} from './ReactRootTags';
31
import {createCache, retainCache} from './ReactFiberCacheComponent.new';
32
33
-function FiberRootNode(containerInfo, tag, hydrate) {
33
+function FiberRootNode(containerInfo, tag, hydrate, identifierPrefix) {
34
this.tag = tag;
35
this.containerInfo = containerInfo;
36
this.pendingChildren = null;
@@ -56,6 +56,8 @@ function FiberRootNode(containerInfo, tag, hydrate) {
56
this.entangledLanes = NoLanes;
57
this.entanglements = createLaneMap(NoLanes);
58
59
+ this.identifierPrefix = identifierPrefix;
60
+
61
if (enableCache) {
62
this.pooledCache = null;
63
this.pooledCacheLanes = NoLanes;
@@ -101,8 +103,14 @@ export function createFiberRoot(
103
hydrationCallbacks: null | SuspenseHydrationCallbacks,
104
isStrictMode: boolean,
105
concurrentUpdatesByDefaultOverride: null | boolean,
106
+ identifierPrefix: string,
107
): FiberRoot {
105
- const root: FiberRoot = (new FiberRootNode(containerInfo, tag, hydrate): any);
108
+ const root: FiberRoot = (new FiberRootNode(
109
+ containerInfo,
110
+ tag,
111
+ hydrate,
112
+ identifierPrefix,
113
+ ): any);
114
if (enableSuspenseCallback) {
115
root.hydrationCallbacks = hydrationCallbacks;
116
}
packages/react-reconciler/src/ReactFiberRoot.old.js
+10
-2
@@ -30,7 +30,7 @@ import {initializeUpdateQueue} from './ReactUpdateQueue.old';
30
import {LegacyRoot, ConcurrentRoot} from './ReactRootTags';
31
import {createCache, retainCache} from './ReactFiberCacheComponent.old';
32
33
-function FiberRootNode(containerInfo, tag, hydrate) {
33
+function FiberRootNode(containerInfo, tag, hydrate, identifierPrefix) {
34
this.tag = tag;
35
this.containerInfo = containerInfo;
36
this.pendingChildren = null;
@@ -56,6 +56,8 @@ function FiberRootNode(containerInfo, tag, hydrate) {
56
this.entangledLanes = NoLanes;
57
this.entanglements = createLaneMap(NoLanes);
58
59
+ this.identifierPrefix = identifierPrefix;
60
+
61
if (enableCache) {
62
this.pooledCache = null;
63
this.pooledCacheLanes = NoLanes;
@@ -101,8 +103,14 @@ export function createFiberRoot(
103
hydrationCallbacks: null | SuspenseHydrationCallbacks,
104
isStrictMode: boolean,
105
concurrentUpdatesByDefaultOverride: null | boolean,
106
+ identifierPrefix: string,
107
): FiberRoot {
105
- const root: FiberRoot = (new FiberRootNode(containerInfo, tag, hydrate): any);
108
+ const root: FiberRoot = (new FiberRootNode(
109
+ containerInfo,
110
+ tag,
111
+ hydrate,
112
+ identifierPrefix,
113
+ ): any);
114
if (enableSuspenseCallback) {
115
root.hydrationCallbacks = hydrationCallbacks;
116
}
packages/react-reconciler/src/ReactInternalTypes.js
+7
@@ -239,6 +239,13 @@ type BaseFiberRootProperties = {|
239
240
pooledCache: Cache | null,
241
pooledCacheLanes: Lanes,
242
+
243
+ // TODO: In Fizz, id generation is specific to each server config. Maybe we
244
+ // should do this in Fiber, too? Deferring this decision for now because
245
+ // there's no other place to store the prefix except for an internal field on
246
+ // the public createRoot object, which the fiber tree does not currently have
247
+ // a reference to.
248
+ identifierPrefix: string,
249
|};
250
251
// The following attributes are only used by DevTools and are only present in DEV builds.
packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js
+4
@@ -74,6 +74,8 @@ describe('ReactFiberHostContext', () => {
74
ConcurrentRoot,
75
false,
76
null,
77
+ false,
78
+ '',
79
);
80
act(() => {
81
Renderer.updateContainer(
@@ -135,6 +137,8 @@ describe('ReactFiberHostContext', () => {
137
ConcurrentRoot,
138
false,
139
null,
140
+ false,
141
+ '',
142
);
143
act(() => {
144
Renderer.updateContainer(
packages/react-server/src/ReactFizzHooks.js
+9
-10
@@ -22,6 +22,8 @@ import type {Task} from './ReactFizzServer';
22
import {readContext as readContextImpl} from './ReactFizzNewContext';
23
import {getTreeId} from './ReactFizzTreeContext';
24
25
+import {makeId} from './ReactServerFormatConfig';
26
+
27
import {enableCache} from 'shared/ReactFeatureFlags';
28
import is from 'shared/objectIs';
29
@@ -512,18 +514,15 @@ function useId(): string {
514
const task: Task = (currentlyRenderingTask: any);
515
const treeId = getTreeId(task.treeContext);
516
515
- // Use a captial R prefix for server-generated ids.
516
- let id = 'R:' + treeId;
517
-
518
- // Unless this is the first id at this level, append a number at the end
519
- // that represents the position of this useId hook among all the useId
520
- // hooks for this fiber.
521
- const localId = localIdCounter++;
522
- if (localId > 0) {
523
- id += ':' + localId.toString(32);
517
+ const responseState = currentResponseState;
518
+ if (responseState === null) {
519
+ throw new Error(
520
+ 'Invalid hook call. Hooks can only be called inside of the body of a function component.',
521
+ );
522
}
523
526
- return id;
524
+ const localId = localIdCounter++;
525
+ return makeId(responseState, treeId, localId);
526
}
527
528
function unsupportedRefresh() {
packages/react-server/src/forks/ReactServerFormatConfig.custom.js
+1
@@ -35,6 +35,7 @@ export const getChildFormatContext = $$$hostConfig.getChildFormatContext;
35
export const UNINITIALIZED_SUSPENSE_BOUNDARY_ID =
36
$$$hostConfig.UNINITIALIZED_SUSPENSE_BOUNDARY_ID;
37
export const assignSuspenseBoundaryID = $$$hostConfig.assignSuspenseBoundaryID;
38
+export const makeId = $$$hostConfig.makeId;
39
export const pushTextInstance = $$$hostConfig.pushTextInstance;
40
export const pushStartInstance = $$$hostConfig.pushStartInstance;
41
export const pushEndInstance = $$$hostConfig.pushEndInstance;
packages/react-test-renderer/src/ReactTestRenderer.js
+1
@@ -471,6 +471,7 @@ function create(element: React$Element<any>, options: TestRendererOptions) {
471
null,
472
isStrictMode,
473
concurrentUpdatesByDefault,
474
+ '',
475
);
476
477
if (root == null) {