@samitouri / QOS-React-2 / commits / 4729ff6d1f

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) {