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.
This commit is contained in:
Andrew Clark authored and GitHub committed 2021-12-02 17:49:43 -08:00
1 parent 71d16750c5
commit 4729ff6d1f
21 files changed
+222 -35

No files matched your search

+9 -1
View File
@@ -66,7 +66,15 @@ class Surface extends React.Component {
this._surface = Mode.Surface(+width, +height, this._tagRef);
this._mountNode = createContainer(this._surface, LegacyRoot, false, null);
this._mountNode = createContainer(
this._surface,
LegacyRoot,
false,
null,
false,
false,
'',
);
updateContainer(this.props.children, this._mountNode, this);
}
+63 -1
View File
@@ -94,7 +94,7 @@ describe('useId', () => {
function normalizeTreeIdForTesting(id) {
const [serverClientPrefix, base32, hookIndex] = id.split(':');
if (serverClientPrefix === 'r') {
if (serverClientPrefix.endsWith('r')) {
// Client ids aren't stable. For testing purposes, strip out the counter.
return (
'CLIENT_GENERATED_ID' +
@@ -569,4 +569,66 @@ describe('useId', () => {
// Should have hydrated successfully
expect(span.current).toBe(dehydratedSpan);
});
test('identifierPrefix option', async () => {
function Child() {
const id = useId();
return <div>{id}</div>;
}
function App({showMore}) {
return (
<>
<Child />
<Child />
{showMore && <Child />}
</>
);
}
await serverAct(async () => {
const {pipe} = ReactDOMFizzServer.renderToPipeableStream(<App />, {
identifierPrefix: 'custom-prefix-',
});
pipe(writable);
});
let root;
await clientAct(async () => {
root = ReactDOM.hydrateRoot(container, <App />, {
identifierPrefix: 'custom-prefix-',
});
});
expect(container).toMatchInlineSnapshot(`
<div
id="container"
>
<div>
custom-prefix-R:1
</div>
<div>
custom-prefix-R:2
</div>
</div>
`);
// Mount a new, client-only id
await clientAct(async () => {
root.render(<App showMore={true} />);
});
expect(container).toMatchInlineSnapshot(`
<div
id="container"
>
<div>
custom-prefix-R:1
</div>
<div>
custom-prefix-R:2
</div>
<div>
custom-prefix-r:0
</div>
</div>
`);
});
});
+1
View File
@@ -121,6 +121,7 @@ function legacyCreateRootFromDOMContainer(
null, // hydrationCallbacks
false, // isStrictMode
false, // concurrentUpdatesByDefaultOverride,
'', // identiferPrefix
);
markContainerAsRoot(root.current, container);
+37 -14
View File
@@ -32,6 +32,7 @@ export type CreateRootOptions = {
// END OF TODO
unstable_strictMode?: boolean,
unstable_concurrentUpdatesByDefault?: boolean,
identifierPrefix?: string,
...
};
@@ -43,6 +44,7 @@ export type HydrateRootOptions = {
// Options for all roots
unstable_strictMode?: boolean,
unstable_concurrentUpdatesByDefault?: boolean,
identifierPrefix?: string,
...
};
@@ -158,13 +160,22 @@ export function createRoot(
null;
// END TODO
const isStrictMode = options != null && options.unstable_strictMode === true;
let concurrentUpdatesByDefaultOverride = null;
if (allowConcurrentByDefault) {
concurrentUpdatesByDefaultOverride =
options != null && options.unstable_concurrentUpdatesByDefault != null
? options.unstable_concurrentUpdatesByDefault
: null;
let isStrictMode = false;
let concurrentUpdatesByDefaultOverride = false;
let identifierPrefix = '';
if (options !== null && options !== undefined) {
if (options.unstable_strictMode === true) {
isStrictMode = true;
}
if (
allowConcurrentByDefault &&
options.unstable_concurrentUpdatesByDefault === true
) {
concurrentUpdatesByDefaultOverride = true;
}
if (options.identifierPrefix !== undefined) {
identifierPrefix = options.identifierPrefix;
}
}
const root = createContainer(
@@ -174,6 +185,7 @@ export function createRoot(
hydrationCallbacks,
isStrictMode,
concurrentUpdatesByDefaultOverride,
identifierPrefix,
);
markContainerAsRoot(root.current, container);
@@ -217,15 +229,25 @@ export function hydrateRoot(
// For now we reuse the whole bag of options since they contain
// the hydration callbacks.
const hydrationCallbacks = options != null ? options : null;
// TODO: Delete this option
const mutableSources = (options != null && options.hydratedSources) || null;
const isStrictMode = options != null && options.unstable_strictMode === true;
let concurrentUpdatesByDefaultOverride = null;
if (allowConcurrentByDefault) {
concurrentUpdatesByDefaultOverride =
options != null && options.unstable_concurrentUpdatesByDefault != null
? options.unstable_concurrentUpdatesByDefault
: null;
let isStrictMode = false;
let concurrentUpdatesByDefaultOverride = false;
let identifierPrefix = '';
if (options !== null && options !== undefined) {
if (options.unstable_strictMode === true) {
isStrictMode = true;
}
if (
allowConcurrentByDefault &&
options.unstable_concurrentUpdatesByDefault === true
) {
concurrentUpdatesByDefaultOverride = true;
}
if (options.identifierPrefix !== undefined) {
identifierPrefix = options.identifierPrefix;
}
}
const root = createContainer(
@@ -235,6 +257,7 @@ export function hydrateRoot(
hydrationCallbacks,
isStrictMode,
concurrentUpdatesByDefaultOverride,
identifierPrefix,
);
markContainerAsRoot(root.current, container);
// This can't be a comment node since hydration doesn't work on comment nodes anyway.
@@ -64,6 +64,7 @@ export type ResponseState = {
placeholderPrefix: PrecomputedChunk,
segmentPrefix: PrecomputedChunk,
boundaryPrefix: string,
idPrefix: string,
nextSuspenseID: number,
sentCompleteSegmentFunction: boolean,
sentCompleteBoundaryFunction: boolean,
@@ -125,6 +126,7 @@ export function createResponseState(
placeholderPrefix: stringToPrecomputedChunk(idPrefix + 'P:'),
segmentPrefix: stringToPrecomputedChunk(idPrefix + 'S:'),
boundaryPrefix: idPrefix + 'B:',
idPrefix: idPrefix + 'R:',
nextSuspenseID: 0,
sentCompleteSegmentFunction: false,
sentCompleteBoundaryFunction: false,
@@ -229,6 +231,25 @@ export function assignSuspenseBoundaryID(
);
}
export function makeId(
responseState: ResponseState,
treeId: string,
localId: number,
): string {
const idPrefix = responseState.idPrefix;
let id = idPrefix + treeId;
// Unless this is the first id at this level, append a number at the end
// that represents the position of this useId hook among all the useId
// hooks for this fiber.
if (localId > 0) {
id += ':' + localId.toString(32);
}
return id;
}
function encodeHTMLTextNode(text: string): string {
return escapeTextForBrowser(text);
}
@@ -34,6 +34,7 @@ export type ResponseState = {
placeholderPrefix: PrecomputedChunk,
segmentPrefix: PrecomputedChunk,
boundaryPrefix: string,
idPrefix: string,
nextSuspenseID: number,
sentCompleteSegmentFunction: boolean,
sentCompleteBoundaryFunction: boolean,
@@ -54,6 +55,7 @@ export function createResponseState(
placeholderPrefix: responseState.placeholderPrefix,
segmentPrefix: responseState.segmentPrefix,
boundaryPrefix: responseState.boundaryPrefix,
idPrefix: responseState.idPrefix,
nextSuspenseID: responseState.nextSuspenseID,
sentCompleteSegmentFunction: responseState.sentCompleteSegmentFunction,
sentCompleteBoundaryFunction: responseState.sentCompleteBoundaryFunction,
@@ -79,6 +81,7 @@ export {
getChildFormatContext,
UNINITIALIZED_SUSPENSE_BOUNDARY_ID,
assignSuspenseBoundaryID,
makeId,
pushStartInstance,
pushEndInstance,
pushStartCompletedSuspenseBoundary,
+1
View File
@@ -213,6 +213,7 @@ function render(
null,
false,
null,
'',
);
roots.set(containerTag, root);
}
+9 -1
View File
@@ -202,7 +202,15 @@ function render(
if (!root) {
// TODO (bvaughn): If we decide to keep the wrapper component,
// We could create a wrapper for containerTag as well to reduce special casing.
root = createContainer(containerTag, LegacyRoot, false, null, false, null);
root = createContainer(
containerTag,
LegacyRoot,
false,
null,
false,
null,
'',
);
roots.set(containerTag, root);
}
updateContainer(element, root, null, callback);
@@ -107,6 +107,14 @@ export function assignSuspenseBoundaryID(
return responseState.nextSuspenseID++;
}
export function makeId(
responseState: ResponseState,
treeId: string,
localId: number,
): string {
throw new Error('Not implemented');
}
const RAW_TEXT = stringToPrecomputedChunk('RCTRawText');
export function pushTextInstance(
+4
View File
@@ -973,6 +973,8 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
false,
null,
null,
false,
'',
);
return {
_Scheduler: Scheduler,
@@ -1000,6 +1002,8 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
false,
null,
null,
false,
'',
);
return {
_Scheduler: Scheduler,
@@ -2035,12 +2035,20 @@ export function getIsUpdatingOpaqueValueInRenderPhaseInDEV(): boolean | void {
function mountId(): string {
const hook = mountWorkInProgressHook();
const root = ((getWorkInProgressRoot(): any): FiberRoot);
// TODO: In Fizz, id generation is specific to each server config. Maybe we
// should do this in Fiber, too? Deferring this decision for now because
// there's no other place to store the prefix except for an internal field on
// the public createRoot object, which the fiber tree does not currently have
// a reference to.
const identifierPrefix = root.identifierPrefix;
let id;
if (getIsHydrating()) {
const treeId = getTreeId();
// Use a captial R prefix for server-generated ids.
id = 'R:' + treeId;
id = identifierPrefix + 'R:' + treeId;
// Unless this is the first id at this level, append a number at the end
// that represents the position of this useId hook among all the useId
@@ -2052,7 +2060,7 @@ function mountId(): string {
} else {
// Use a lowercase r prefix for client-generated ids.
const globalClientId = globalClientIdCounter++;
id = 'r:' + globalClientId.toString(32);
id = identifierPrefix + 'r:' + globalClientId.toString(32);
}
hook.memoizedState = id;
@@ -2035,12 +2035,20 @@ export function getIsUpdatingOpaqueValueInRenderPhaseInDEV(): boolean | void {
function mountId(): string {
const hook = mountWorkInProgressHook();
const root = ((getWorkInProgressRoot(): any): FiberRoot);
// TODO: In Fizz, id generation is specific to each server config. Maybe we
// should do this in Fiber, too? Deferring this decision for now because
// there's no other place to store the prefix except for an internal field on
// the public createRoot object, which the fiber tree does not currently have
// a reference to.
const identifierPrefix = root.identifierPrefix;
let id;
if (getIsHydrating()) {
const treeId = getTreeId();
// Use a captial R prefix for server-generated ids.
id = 'R:' + treeId;
id = identifierPrefix + 'R:' + treeId;
// Unless this is the first id at this level, append a number at the end
// that represents the position of this useId hook among all the useId
@@ -2052,7 +2060,7 @@ function mountId(): string {
} else {
// Use a lowercase r prefix for client-generated ids.
const globalClientId = globalClientIdCounter++;
id = 'r:' + globalClientId.toString(32);
id = identifierPrefix + 'r:' + globalClientId.toString(32);
}
hook.memoizedState = id;
@@ -241,6 +241,7 @@ export function createContainer(
hydrationCallbacks: null | SuspenseHydrationCallbacks,
isStrictMode: boolean,
concurrentUpdatesByDefaultOverride: null | boolean,
identifierPrefix: string,
): OpaqueRoot {
return createFiberRoot(
containerInfo,
@@ -249,6 +250,7 @@ export function createContainer(
hydrationCallbacks,
isStrictMode,
concurrentUpdatesByDefaultOverride,
identifierPrefix,
);
}
@@ -241,6 +241,7 @@ export function createContainer(
hydrationCallbacks: null | SuspenseHydrationCallbacks,
isStrictMode: boolean,
concurrentUpdatesByDefaultOverride: null | boolean,
identifierPrefix: string,
): OpaqueRoot {
return createFiberRoot(
containerInfo,
@@ -249,6 +250,7 @@ export function createContainer(
hydrationCallbacks,
isStrictMode,
concurrentUpdatesByDefaultOverride,
identifierPrefix,
);
}
@@ -30,7 +30,7 @@ import {initializeUpdateQueue} from './ReactUpdateQueue.new';
import {LegacyRoot, ConcurrentRoot} from './ReactRootTags';
import {createCache, retainCache} from './ReactFiberCacheComponent.new';
function FiberRootNode(containerInfo, tag, hydrate) {
function FiberRootNode(containerInfo, tag, hydrate, identifierPrefix) {
this.tag = tag;
this.containerInfo = containerInfo;
this.pendingChildren = null;
@@ -56,6 +56,8 @@ function FiberRootNode(containerInfo, tag, hydrate) {
this.entangledLanes = NoLanes;
this.entanglements = createLaneMap(NoLanes);
this.identifierPrefix = identifierPrefix;
if (enableCache) {
this.pooledCache = null;
this.pooledCacheLanes = NoLanes;
@@ -101,8 +103,14 @@ export function createFiberRoot(
hydrationCallbacks: null | SuspenseHydrationCallbacks,
isStrictMode: boolean,
concurrentUpdatesByDefaultOverride: null | boolean,
identifierPrefix: string,
): FiberRoot {
const root: FiberRoot = (new FiberRootNode(containerInfo, tag, hydrate): any);
const root: FiberRoot = (new FiberRootNode(
containerInfo,
tag,
hydrate,
identifierPrefix,
): any);
if (enableSuspenseCallback) {
root.hydrationCallbacks = hydrationCallbacks;
}
@@ -30,7 +30,7 @@ import {initializeUpdateQueue} from './ReactUpdateQueue.old';
import {LegacyRoot, ConcurrentRoot} from './ReactRootTags';
import {createCache, retainCache} from './ReactFiberCacheComponent.old';
function FiberRootNode(containerInfo, tag, hydrate) {
function FiberRootNode(containerInfo, tag, hydrate, identifierPrefix) {
this.tag = tag;
this.containerInfo = containerInfo;
this.pendingChildren = null;
@@ -56,6 +56,8 @@ function FiberRootNode(containerInfo, tag, hydrate) {
this.entangledLanes = NoLanes;
this.entanglements = createLaneMap(NoLanes);
this.identifierPrefix = identifierPrefix;
if (enableCache) {
this.pooledCache = null;
this.pooledCacheLanes = NoLanes;
@@ -101,8 +103,14 @@ export function createFiberRoot(
hydrationCallbacks: null | SuspenseHydrationCallbacks,
isStrictMode: boolean,
concurrentUpdatesByDefaultOverride: null | boolean,
identifierPrefix: string,
): FiberRoot {
const root: FiberRoot = (new FiberRootNode(containerInfo, tag, hydrate): any);
const root: FiberRoot = (new FiberRootNode(
containerInfo,
tag,
hydrate,
identifierPrefix,
): any);
if (enableSuspenseCallback) {
root.hydrationCallbacks = hydrationCallbacks;
}
+7
View File
@@ -239,6 +239,13 @@ type BaseFiberRootProperties = {|
pooledCache: Cache | null,
pooledCacheLanes: Lanes,
// TODO: In Fizz, id generation is specific to each server config. Maybe we
// should do this in Fiber, too? Deferring this decision for now because
// there's no other place to store the prefix except for an internal field on
// the public createRoot object, which the fiber tree does not currently have
// a reference to.
identifierPrefix: string,
|};
// The following attributes are only used by DevTools and are only present in DEV builds.
@@ -74,6 +74,8 @@ describe('ReactFiberHostContext', () => {
ConcurrentRoot,
false,
null,
false,
'',
);
act(() => {
Renderer.updateContainer(
@@ -135,6 +137,8 @@ describe('ReactFiberHostContext', () => {
ConcurrentRoot,
false,
null,
false,
'',
);
act(() => {
Renderer.updateContainer(
+9 -10
View File
@@ -22,6 +22,8 @@ import type {Task} from './ReactFizzServer';
import {readContext as readContextImpl} from './ReactFizzNewContext';
import {getTreeId} from './ReactFizzTreeContext';
import {makeId} from './ReactServerFormatConfig';
import {enableCache} from 'shared/ReactFeatureFlags';
import is from 'shared/objectIs';
@@ -512,18 +514,15 @@ function useId(): string {
const task: Task = (currentlyRenderingTask: any);
const treeId = getTreeId(task.treeContext);
// Use a captial R prefix for server-generated ids.
let id = 'R:' + treeId;
// Unless this is the first id at this level, append a number at the end
// that represents the position of this useId hook among all the useId
// hooks for this fiber.
const localId = localIdCounter++;
if (localId > 0) {
id += ':' + localId.toString(32);
const responseState = currentResponseState;
if (responseState === null) {
throw new Error(
'Invalid hook call. Hooks can only be called inside of the body of a function component.',
);
}
return id;
const localId = localIdCounter++;
return makeId(responseState, treeId, localId);
}
function unsupportedRefresh() {
@@ -35,6 +35,7 @@ export const getChildFormatContext = $$$hostConfig.getChildFormatContext;
export const UNINITIALIZED_SUSPENSE_BOUNDARY_ID =
$$$hostConfig.UNINITIALIZED_SUSPENSE_BOUNDARY_ID;
export const assignSuspenseBoundaryID = $$$hostConfig.assignSuspenseBoundaryID;
export const makeId = $$$hostConfig.makeId;
export const pushTextInstance = $$$hostConfig.pushTextInstance;
export const pushStartInstance = $$$hostConfig.pushStartInstance;
export const pushEndInstance = $$$hostConfig.pushEndInstance;
+1
View File
@@ -471,6 +471,7 @@ function create(element: React$Element<any>, options: TestRendererOptions) {
null,
isStrictMode,
concurrentUpdatesByDefault,
'',
);
if (root == null) {