Skip to content

Commit 4729ff6

Browse files
authored
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.
1 parent 71d1675 commit 4729ff6

21 files changed

+223
-36
lines changed

packages/react-art/src/ReactART.js

+9-1
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,15 @@ class Surface extends React.Component {
6666

6767
this._surface = Mode.Surface(+width, +height, this._tagRef);
6868

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+
);
7078
updateContainer(this.props.children, this._mountNode, this);
7179
}
7280

packages/react-dom/src/__tests__/ReactDOMUseId-test.js

+63-1
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ describe('useId', () => {
9494

9595
function normalizeTreeIdForTesting(id) {
9696
const [serverClientPrefix, base32, hookIndex] = id.split(':');
97-
if (serverClientPrefix === 'r') {
97+
if (serverClientPrefix.endsWith('r')) {
9898
// Client ids aren't stable. For testing purposes, strip out the counter.
9999
return (
100100
'CLIENT_GENERATED_ID' +
@@ -569,4 +569,66 @@ describe('useId', () => {
569569
// Should have hydrated successfully
570570
expect(span.current).toBe(dehydratedSpan);
571571
});
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+
});
572634
});

packages/react-dom/src/client/ReactDOMLegacy.js

+1
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ function legacyCreateRootFromDOMContainer(
121121
null, // hydrationCallbacks
122122
false, // isStrictMode
123123
false, // concurrentUpdatesByDefaultOverride,
124+
'', // identiferPrefix
124125
);
125126
markContainerAsRoot(root.current, container);
126127

packages/react-dom/src/client/ReactDOMRoot.js

+38-15
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export type CreateRootOptions = {
3232
// END OF TODO
3333
unstable_strictMode?: boolean,
3434
unstable_concurrentUpdatesByDefault?: boolean,
35+
identifierPrefix?: string,
3536
...
3637
};
3738

@@ -43,6 +44,7 @@ export type HydrateRootOptions = {
4344
// Options for all roots
4445
unstable_strictMode?: boolean,
4546
unstable_concurrentUpdatesByDefault?: boolean,
47+
identifierPrefix?: string,
4648
...
4749
};
4850

@@ -158,13 +160,22 @@ export function createRoot(
158160
null;
159161
// END TODO
160162

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+
}
168179
}
169180

170181
const root = createContainer(
@@ -174,6 +185,7 @@ export function createRoot(
174185
hydrationCallbacks,
175186
isStrictMode,
176187
concurrentUpdatesByDefaultOverride,
188+
identifierPrefix,
177189
);
178190
markContainerAsRoot(root.current, container);
179191

@@ -217,15 +229,25 @@ export function hydrateRoot(
217229
// For now we reuse the whole bag of options since they contain
218230
// the hydration callbacks.
219231
const hydrationCallbacks = options != null ? options : null;
232+
// TODO: Delete this option
220233
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+
}
229251
}
230252

231253
const root = createContainer(
@@ -235,6 +257,7 @@ export function hydrateRoot(
235257
hydrationCallbacks,
236258
isStrictMode,
237259
concurrentUpdatesByDefaultOverride,
260+
identifierPrefix,
238261
);
239262
markContainerAsRoot(root.current, container);
240263
// This can't be a comment node since hydration doesn't work on comment nodes anyway.

packages/react-dom/src/server/ReactDOMServerFormatConfig.js

+21
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export type ResponseState = {
6464
placeholderPrefix: PrecomputedChunk,
6565
segmentPrefix: PrecomputedChunk,
6666
boundaryPrefix: string,
67+
idPrefix: string,
6768
nextSuspenseID: number,
6869
sentCompleteSegmentFunction: boolean,
6970
sentCompleteBoundaryFunction: boolean,
@@ -125,6 +126,7 @@ export function createResponseState(
125126
placeholderPrefix: stringToPrecomputedChunk(idPrefix + 'P:'),
126127
segmentPrefix: stringToPrecomputedChunk(idPrefix + 'S:'),
127128
boundaryPrefix: idPrefix + 'B:',
129+
idPrefix: idPrefix + 'R:',
128130
nextSuspenseID: 0,
129131
sentCompleteSegmentFunction: false,
130132
sentCompleteBoundaryFunction: false,
@@ -229,6 +231,25 @@ export function assignSuspenseBoundaryID(
229231
);
230232
}
231233

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+
232253
function encodeHTMLTextNode(text: string): string {
233254
return escapeTextForBrowser(text);
234255
}

packages/react-dom/src/server/ReactDOMServerLegacyFormatConfig.js

+3
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export type ResponseState = {
3434
placeholderPrefix: PrecomputedChunk,
3535
segmentPrefix: PrecomputedChunk,
3636
boundaryPrefix: string,
37+
idPrefix: string,
3738
nextSuspenseID: number,
3839
sentCompleteSegmentFunction: boolean,
3940
sentCompleteBoundaryFunction: boolean,
@@ -54,6 +55,7 @@ export function createResponseState(
5455
placeholderPrefix: responseState.placeholderPrefix,
5556
segmentPrefix: responseState.segmentPrefix,
5657
boundaryPrefix: responseState.boundaryPrefix,
58+
idPrefix: responseState.idPrefix,
5759
nextSuspenseID: responseState.nextSuspenseID,
5860
sentCompleteSegmentFunction: responseState.sentCompleteSegmentFunction,
5961
sentCompleteBoundaryFunction: responseState.sentCompleteBoundaryFunction,
@@ -79,6 +81,7 @@ export {
7981
getChildFormatContext,
8082
UNINITIALIZED_SUSPENSE_BOUNDARY_ID,
8183
assignSuspenseBoundaryID,
84+
makeId,
8285
pushStartInstance,
8386
pushEndInstance,
8487
pushStartCompletedSuspenseBoundary,

packages/react-native-renderer/src/ReactFabric.js

+1
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ function render(
213213
null,
214214
false,
215215
null,
216+
'',
216217
);
217218
roots.set(containerTag, root);
218219
}

packages/react-native-renderer/src/ReactNativeRenderer.js

+9-1
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,15 @@ function render(
202202
if (!root) {
203203
// TODO (bvaughn): If we decide to keep the wrapper component,
204204
// 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+
);
206214
roots.set(containerTag, root);
207215
}
208216
updateContainer(element, root, null, callback);

packages/react-native-renderer/src/server/ReactNativeServerFormatConfig.js

+8
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,14 @@ export function assignSuspenseBoundaryID(
107107
return responseState.nextSuspenseID++;
108108
}
109109

110+
export function makeId(
111+
responseState: ResponseState,
112+
treeId: string,
113+
localId: number,
114+
): string {
115+
throw new Error('Not implemented');
116+
}
117+
110118
const RAW_TEXT = stringToPrecomputedChunk('RCTRawText');
111119

112120
export function pushTextInstance(

packages/react-noop-renderer/src/createReactNoop.js

+4
Original file line numberDiff line numberDiff line change
@@ -973,6 +973,8 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
973973
false,
974974
null,
975975
null,
976+
false,
977+
'',
976978
);
977979
return {
978980
_Scheduler: Scheduler,
@@ -1000,6 +1002,8 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
10001002
false,
10011003
null,
10021004
null,
1005+
false,
1006+
'',
10031007
);
10041008
return {
10051009
_Scheduler: Scheduler,

packages/react-reconciler/src/ReactFiberHooks.new.js

+10-2
Original file line numberDiff line numberDiff line change
@@ -2035,12 +2035,20 @@ export function getIsUpdatingOpaqueValueInRenderPhaseInDEV(): boolean | void {
20352035
function mountId(): string {
20362036
const hook = mountWorkInProgressHook();
20372037

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+
20382046
let id;
20392047
if (getIsHydrating()) {
20402048
const treeId = getTreeId();
20412049

20422050
// Use a captial R prefix for server-generated ids.
2043-
id = 'R:' + treeId;
2051+
id = identifierPrefix + 'R:' + treeId;
20442052

20452053
// Unless this is the first id at this level, append a number at the end
20462054
// that represents the position of this useId hook among all the useId
@@ -2052,7 +2060,7 @@ function mountId(): string {
20522060
} else {
20532061
// Use a lowercase r prefix for client-generated ids.
20542062
const globalClientId = globalClientIdCounter++;
2055-
id = 'r:' + globalClientId.toString(32);
2063+
id = identifierPrefix + 'r:' + globalClientId.toString(32);
20562064
}
20572065

20582066
hook.memoizedState = id;

packages/react-reconciler/src/ReactFiberHooks.old.js

+10-2
Original file line numberDiff line numberDiff line change
@@ -2035,12 +2035,20 @@ export function getIsUpdatingOpaqueValueInRenderPhaseInDEV(): boolean | void {
20352035
function mountId(): string {
20362036
const hook = mountWorkInProgressHook();
20372037

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+
20382046
let id;
20392047
if (getIsHydrating()) {
20402048
const treeId = getTreeId();
20412049

20422050
// Use a captial R prefix for server-generated ids.
2043-
id = 'R:' + treeId;
2051+
id = identifierPrefix + 'R:' + treeId;
20442052

20452053
// Unless this is the first id at this level, append a number at the end
20462054
// that represents the position of this useId hook among all the useId
@@ -2052,7 +2060,7 @@ function mountId(): string {
20522060
} else {
20532061
// Use a lowercase r prefix for client-generated ids.
20542062
const globalClientId = globalClientIdCounter++;
2055-
id = 'r:' + globalClientId.toString(32);
2063+
id = identifierPrefix + 'r:' + globalClientId.toString(32);
20562064
}
20572065

20582066
hook.memoizedState = id;

packages/react-reconciler/src/ReactFiberReconciler.new.js

+2
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ export function createContainer(
241241
hydrationCallbacks: null | SuspenseHydrationCallbacks,
242242
isStrictMode: boolean,
243243
concurrentUpdatesByDefaultOverride: null | boolean,
244+
identifierPrefix: string,
244245
): OpaqueRoot {
245246
return createFiberRoot(
246247
containerInfo,
@@ -249,6 +250,7 @@ export function createContainer(
249250
hydrationCallbacks,
250251
isStrictMode,
251252
concurrentUpdatesByDefaultOverride,
253+
identifierPrefix,
252254
);
253255
}
254256

0 commit comments

Comments
 (0)