Skip to content

Commit dd4950c

Browse files
authored
[Flight] Implement useId hook (#24172)
* Implements useId hook for Flight server. The approach for ids for Flight is different from Fizz/Client where there is a need for determinancy. Flight rendered elements will not be rendered on the client and as such the ids generated in a request only need to be unique. However since FLight does support refetching subtrees it is possible a client will need to patch up a part of the tree rather than replacing the entire thing so it is not safe to use a simple incrementing counter. To solve for this we allow the caller to specify a prefix. On an initial fetch it is likely this will be empty but on refetches or subtrees we expect to have a client `useId` provide the prefix since it will guaranteed be unique for that subtree and thus for the entire tree. It is also possible that we will automatically provide prefixes based on a client/Fizz useId on refetches in addition to the core change I also modified the structure of options for renderToReadableStream where `onError`, `context`, and the new `identifierPrefix` are properties of an Options object argument to avoid the clumsiness of a growing list of optional function arguments. * defend against useId call outside of rendering * switch to S from F for Server Component ids * default to empty string identifier prefix * Add a test demonstrating that there is no warning when double rendering on the client a server component that used useId * lints and gates
1 parent 26a5b3c commit dd4950c

File tree

8 files changed

+149
-20
lines changed

8 files changed

+149
-20
lines changed

packages/react-client/src/__tests__/ReactFlight-test.js

+96-4
Original file line numberDiff line numberDiff line change
@@ -512,6 +512,99 @@ describe('ReactFlight', () => {
512512
);
513513
});
514514

515+
describe('Hooks', () => {
516+
function DivWithId({children}) {
517+
const id = React.useId();
518+
return <div prop={id}>{children}</div>;
519+
}
520+
521+
it('should support useId', () => {
522+
function App() {
523+
return (
524+
<>
525+
<DivWithId />
526+
<DivWithId />
527+
</>
528+
);
529+
}
530+
531+
const transport = ReactNoopFlightServer.render(<App />);
532+
act(() => {
533+
ReactNoop.render(ReactNoopFlightClient.read(transport));
534+
});
535+
expect(ReactNoop).toMatchRenderedOutput(
536+
<>
537+
<div prop=":S1:" />
538+
<div prop=":S2:" />
539+
</>,
540+
);
541+
});
542+
543+
it('accepts an identifier prefix that prefixes generated ids', () => {
544+
function App() {
545+
return (
546+
<>
547+
<DivWithId />
548+
<DivWithId />
549+
</>
550+
);
551+
}
552+
553+
const transport = ReactNoopFlightServer.render(<App />, {
554+
identifierPrefix: 'foo',
555+
});
556+
act(() => {
557+
ReactNoop.render(ReactNoopFlightClient.read(transport));
558+
});
559+
expect(ReactNoop).toMatchRenderedOutput(
560+
<>
561+
<div prop=":fooS1:" />
562+
<div prop=":fooS2:" />
563+
</>,
564+
);
565+
});
566+
567+
it('[TODO] it does not warn if you render a server element passed to a client module reference twice on the client when using useId', async () => {
568+
// @TODO Today if you render a server component with useId and pass it to a client component and that client component renders the element in two or more
569+
// places the id used on the server will be duplicated in the client. This is a deviation from the guarantees useId makes for Fizz/Client and is a consequence
570+
// of the fact that the server component is actually rendered on the server and is reduced to a set of host elements before being passed to the Client component
571+
// so the output passed to the Client has no knowledge of the useId use. In the future we would like to add a DEV warning when this happens. For now
572+
// we just accept that it is a nuance of useId in Flight
573+
function App() {
574+
const id = React.useId();
575+
const div = <div prop={id}>{id}</div>;
576+
return <ClientDoublerModuleRef el={div} />;
577+
}
578+
579+
function ClientDoubler({el}) {
580+
Scheduler.unstable_yieldValue('ClientDoubler');
581+
return (
582+
<>
583+
{el}
584+
{el}
585+
</>
586+
);
587+
}
588+
589+
const ClientDoublerModuleRef = moduleReference(ClientDoubler);
590+
591+
const transport = ReactNoopFlightServer.render(<App />);
592+
expect(Scheduler).toHaveYielded([]);
593+
594+
act(() => {
595+
ReactNoop.render(ReactNoopFlightClient.read(transport));
596+
});
597+
598+
expect(Scheduler).toHaveYielded(['ClientDoubler']);
599+
expect(ReactNoop).toMatchRenderedOutput(
600+
<>
601+
<div prop=":S1:">:S1:</div>
602+
<div prop=":S1:">:S1:</div>
603+
</>,
604+
);
605+
});
606+
});
607+
515608
describe('ServerContext', () => {
516609
// @gate enableServerContext
517610
it('supports basic createServerContext usage', () => {
@@ -759,15 +852,14 @@ describe('ReactFlight', () => {
759852
function Bar() {
760853
return <span>{React.useContext(ServerContext)}</span>;
761854
}
762-
const transport = ReactNoopFlightServer.render(<Bar />, {}, [
763-
['ServerContext', 'Override'],
764-
]);
855+
const transport = ReactNoopFlightServer.render(<Bar />, {
856+
context: [['ServerContext', 'Override']],
857+
});
765858

766859
act(() => {
767860
const flightModel = ReactNoopFlightClient.read(transport);
768861
ReactNoop.render(flightModel);
769862
});
770-
771863
expect(ReactNoop).toMatchRenderedOutput(<span>Override</span>);
772864
});
773865

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

+5-6
Original file line numberDiff line numberDiff line change
@@ -61,20 +61,19 @@ const ReactNoopFlightServer = ReactFlightServer({
6161

6262
type Options = {
6363
onError?: (error: mixed) => void,
64+
context?: Array<[string, ServerContextJSONValue]>,
65+
identifierPrefix?: string,
6466
};
6567

66-
function render(
67-
model: ReactModel,
68-
options?: Options,
69-
context?: Array<[string, ServerContextJSONValue]>,
70-
): Destination {
68+
function render(model: ReactModel, options?: Options): Destination {
7169
const destination: Destination = [];
7270
const bundlerConfig = undefined;
7371
const request = ReactNoopFlightServer.createRequest(
7472
model,
7573
bundlerConfig,
7674
options ? options.onError : undefined,
77-
context,
75+
options ? options.context : undefined,
76+
options ? options.identifierPrefix : undefined,
7877
);
7978
ReactNoopFlightServer.startWork(request);
8079
ReactNoopFlightServer.startFlowing(request, destination);

packages/react-server-dom-relay/src/ReactFlightDOMRelayServer.js

+3
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121

2222
type Options = {
2323
onError?: (error: mixed) => void,
24+
identifierPrefix?: string,
2425
};
2526

2627
function render(
@@ -33,6 +34,8 @@ function render(
3334
model,
3435
config,
3536
options ? options.onError : undefined,
37+
undefined, // not currently set up to supply context overrides
38+
options ? options.identifierPrefix : undefined,
3639
);
3740
startWork(request);
3841
startFlowing(request, destination);

packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js

+4-2
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,21 @@ import {
1919

2020
type Options = {
2121
onError?: (error: mixed) => void,
22+
context?: Array<[string, ServerContextJSONValue]>,
23+
identifierPrefix?: string,
2224
};
2325

2426
function renderToReadableStream(
2527
model: ReactModel,
2628
webpackMap: BundlerConfig,
2729
options?: Options,
28-
context?: Array<[string, ServerContextJSONValue]>,
2930
): ReadableStream {
3031
const request = createRequest(
3132
model,
3233
webpackMap,
3334
options ? options.onError : undefined,
34-
context,
35+
options ? options.context : undefined,
36+
options ? options.identifierPrefix : undefined,
3537
);
3638
const stream = new ReadableStream(
3739
{

packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js

+4-1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ function createDrainHandler(destination, request) {
2424

2525
type Options = {
2626
onError?: (error: mixed) => void,
27+
context?: Array<[string, ServerContextJSONValue]>,
28+
identifierPrefix?: string,
2729
};
2830

2931
type PipeableStream = {|
@@ -40,7 +42,8 @@ function renderToPipeableStream(
4042
model,
4143
webpackMap,
4244
options ? options.onError : undefined,
43-
context,
45+
options ? options.context : undefined,
46+
options ? options.identifierPrefix : undefined,
4447
);
4548
let hasStartedFlowing = false;
4649
startWork(request);

packages/react-server/src/ReactFlightHooks.js

+21-1
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,21 @@
88
*/
99

1010
import type {Dispatcher as DispatcherType} from 'react-reconciler/src/ReactInternalTypes';
11+
import type {Request} from './ReactFlightServer';
1112
import type {ReactServerContext} from 'shared/ReactTypes';
1213
import {REACT_SERVER_CONTEXT_TYPE} from 'shared/ReactSymbols';
1314
import {readContext as readContextImpl} from './ReactFlightNewContext';
1415

16+
let currentRequest = null;
17+
18+
export function prepareToUseHooksForRequest(request: Request) {
19+
currentRequest = request;
20+
}
21+
22+
export function resetHooksForRequest() {
23+
currentRequest = null;
24+
}
25+
1526
function readContext<T>(context: ReactServerContext<T>): T {
1627
if (__DEV__) {
1728
if (context.$$typeof !== REACT_SERVER_CONTEXT_TYPE) {
@@ -61,7 +72,7 @@ export const Dispatcher: DispatcherType = {
6172
useLayoutEffect: (unsupportedHook: any),
6273
useImperativeHandle: (unsupportedHook: any),
6374
useEffect: (unsupportedHook: any),
64-
useId: (unsupportedHook: any),
75+
useId,
6576
useMutableSource: (unsupportedHook: any),
6677
useSyncExternalStore: (unsupportedHook: any),
6778
useCacheRefresh(): <T>(?() => T, ?T) => void {
@@ -91,3 +102,12 @@ export function setCurrentCache(cache: Map<Function, mixed> | null) {
91102
export function getCurrentCache() {
92103
return currentCache;
93104
}
105+
106+
function useId(): string {
107+
if (currentRequest === null) {
108+
throw new Error('useId can only be used while React is rendering');
109+
}
110+
const id = currentRequest.identifierCount++;
111+
// use 'S' for Flight components to distinguish from 'R' and 'r' in Fizz/Client
112+
return ':' + currentRequest.identifierPrefix + 'S' + id.toString(32) + ':';
113+
}

packages/react-server/src/ReactFlightServer.js

+14-5
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,13 @@ import {
3939
isModuleReference,
4040
} from './ReactFlightServerConfig';
4141

42-
import {Dispatcher, getCurrentCache, setCurrentCache} from './ReactFlightHooks';
42+
import {
43+
Dispatcher,
44+
getCurrentCache,
45+
prepareToUseHooksForRequest,
46+
resetHooksForRequest,
47+
setCurrentCache,
48+
} from './ReactFlightHooks';
4349
import {
4450
pushProvider,
4551
popProvider,
@@ -102,14 +108,12 @@ export type Request = {
102108
writtenSymbols: Map<Symbol, number>,
103109
writtenModules: Map<ModuleKey, number>,
104110
writtenProviders: Map<string, number>,
111+
identifierPrefix: string,
112+
identifierCount: number,
105113
onError: (error: mixed) => void,
106114
toJSON: (key: string, value: ReactModel) => ReactJSONValue,
107115
};
108116

109-
export type Options = {
110-
onError?: (error: mixed) => void,
111-
};
112-
113117
const ReactCurrentDispatcher = ReactSharedInternals.ReactCurrentDispatcher;
114118

115119
function defaultErrorHandler(error: mixed) {
@@ -126,6 +130,7 @@ export function createRequest(
126130
bundlerConfig: BundlerConfig,
127131
onError: void | ((error: mixed) => void),
128132
context?: Array<[string, ServerContextJSONValue]>,
133+
identifierPrefix?: string,
129134
): Request {
130135
const pingedSegments = [];
131136
const request = {
@@ -143,6 +148,8 @@ export function createRequest(
143148
writtenSymbols: new Map(),
144149
writtenModules: new Map(),
145150
writtenProviders: new Map(),
151+
identifierPrefix: identifierPrefix || '',
152+
identifierCount: 1,
146153
onError: onError === undefined ? defaultErrorHandler : onError,
147154
toJSON: function(key: string, value: ReactModel): ReactJSONValue {
148155
return resolveModelToJSON(request, this, key, value);
@@ -826,6 +833,7 @@ function performWork(request: Request): void {
826833
const prevCache = getCurrentCache();
827834
ReactCurrentDispatcher.current = Dispatcher;
828835
setCurrentCache(request.cache);
836+
prepareToUseHooksForRequest(request);
829837

830838
try {
831839
const pingedSegments = request.pingedSegments;
@@ -843,6 +851,7 @@ function performWork(request: Request): void {
843851
} finally {
844852
ReactCurrentDispatcher.current = prevDispatcher;
845853
setCurrentCache(prevCache);
854+
resetHooksForRequest();
846855
}
847856
}
848857

scripts/error-codes/codes.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -417,5 +417,6 @@
417417
"429": "ServerContext: %s already defined",
418418
"430": "ServerContext can only have a value prop and children. Found: %s",
419419
"431": "React elements are not allowed in ServerContext",
420-
"432": "This Suspense boundary was aborted by the server"
420+
"432": "This Suspense boundary was aborted by the server",
421+
"433": "useId can only be used while React is rendering"
421422
}

0 commit comments

Comments
 (0)