@samitouri / QOS-React-2 / commits / dd4950c90e

[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

Josh Story committed May 31, 2022 at 14:53 UTC dd4950c90e65a6c53870fd61d2aea67bce7d6713
8 files changed +149 -20
packages/react-client/src/__tests__/ReactFlight-test.js
+96 -4
@@ -512,6 +512,99 @@ describe('ReactFlight', () => {
512 );
513 });
514
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 +
608 describe('ServerContext', () => {
609 // @gate enableServerContext
610 it('supports basic createServerContext usage', () => {
@@ -759,15 +852,14 @@ describe('ReactFlight', () => {
852 function Bar() {
853 return <span>{React.useContext(ServerContext)}</span>;
854 }
762 - const transport = ReactNoopFlightServer.render(<Bar />, {}, [
763 - ['ServerContext', 'Override'],
764 - ]);
855 + const transport = ReactNoopFlightServer.render(<Bar />, {
856 + context: [['ServerContext', 'Override']],
857 + });
858
859 act(() => {
860 const flightModel = ReactNoopFlightClient.read(transport);
861 ReactNoop.render(flightModel);
862 });
770 -
863 expect(ReactNoop).toMatchRenderedOutput(<span>Override</span>);
864 });
865
packages/react-noop-renderer/src/ReactNoopFlightServer.js
+5 -6
@@ -61,20 +61,19 @@ const ReactNoopFlightServer = ReactFlightServer({
61
62 type Options = {
63 onError?: (error: mixed) => void,
64 + context?: Array<[string, ServerContextJSONValue]>,
65 + identifierPrefix?: string,
66 };
67
66 -function render(
67 - model: ReactModel,
68 - options?: Options,
69 - context?: Array<[string, ServerContextJSONValue]>,
70 -): Destination {
68 +function render(model: ReactModel, options?: Options): Destination {
69 const destination: Destination = [];
70 const bundlerConfig = undefined;
71 const request = ReactNoopFlightServer.createRequest(
72 model,
73 bundlerConfig,
74 options ? options.onError : undefined,
77 - context,
75 + options ? options.context : undefined,
76 + options ? options.identifierPrefix : undefined,
77 );
78 ReactNoopFlightServer.startWork(request);
79 ReactNoopFlightServer.startFlowing(request, destination);
packages/react-server-dom-relay/src/ReactFlightDOMRelayServer.js
+3
@@ -21,6 +21,7 @@ import {
21
22 type Options = {
23 onError?: (error: mixed) => void,
24 + identifierPrefix?: string,
25 };
26
27 function render(
@@ -33,6 +34,8 @@ function render(
34 model,
35 config,
36 options ? options.onError : undefined,
37 + undefined, // not currently set up to supply context overrides
38 + options ? options.identifierPrefix : undefined,
39 );
40 startWork(request);
41 startFlowing(request, destination);
packages/react-server-dom-webpack/src/ReactFlightDOMServerBrowser.js
+4 -2
@@ -19,19 +19,21 @@ import {
19
20 type Options = {
21 onError?: (error: mixed) => void,
22 + context?: Array<[string, ServerContextJSONValue]>,
23 + identifierPrefix?: string,
24 };
25
26 function renderToReadableStream(
27 model: ReactModel,
28 webpackMap: BundlerConfig,
29 options?: Options,
28 - context?: Array<[string, ServerContextJSONValue]>,
30 ): ReadableStream {
31 const request = createRequest(
32 model,
33 webpackMap,
34 options ? options.onError : undefined,
34 - context,
35 + options ? options.context : undefined,
36 + options ? options.identifierPrefix : undefined,
37 );
38 const stream = new ReadableStream(
39 {
packages/react-server-dom-webpack/src/ReactFlightDOMServerNode.js
+4 -1
@@ -24,6 +24,8 @@ function createDrainHandler(destination, request) {
24
25 type Options = {
26 onError?: (error: mixed) => void,
27 + context?: Array<[string, ServerContextJSONValue]>,
28 + identifierPrefix?: string,
29 };
30
31 type PipeableStream = {|
@@ -40,7 +42,8 @@ function renderToPipeableStream(
42 model,
43 webpackMap,
44 options ? options.onError : undefined,
43 - context,
45 + options ? options.context : undefined,
46 + options ? options.identifierPrefix : undefined,
47 );
48 let hasStartedFlowing = false;
49 startWork(request);
packages/react-server/src/ReactFlightHooks.js
+21 -1
@@ -8,10 +8,21 @@
8 */
9
10 import type {Dispatcher as DispatcherType} from 'react-reconciler/src/ReactInternalTypes';
11 +import type {Request} from './ReactFlightServer';
12 import type {ReactServerContext} from 'shared/ReactTypes';
13 import {REACT_SERVER_CONTEXT_TYPE} from 'shared/ReactSymbols';
14 import {readContext as readContextImpl} from './ReactFlightNewContext';
15
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 +
26 function readContext<T>(context: ReactServerContext<T>): T {
27 if (__DEV__) {
28 if (context.$$typeof !== REACT_SERVER_CONTEXT_TYPE) {
@@ -61,7 +72,7 @@ export const Dispatcher: DispatcherType = {
72 useLayoutEffect: (unsupportedHook: any),
73 useImperativeHandle: (unsupportedHook: any),
74 useEffect: (unsupportedHook: any),
64 - useId: (unsupportedHook: any),
75 + useId,
76 useMutableSource: (unsupportedHook: any),
77 useSyncExternalStore: (unsupportedHook: any),
78 useCacheRefresh(): <T>(?() => T, ?T) => void {
@@ -91,3 +102,12 @@ export function setCurrentCache(cache: Map<Function, mixed> | null) {
102 export function getCurrentCache() {
103 return currentCache;
104 }
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
@@ -39,7 +39,13 @@ import {
39 isModuleReference,
40 } from './ReactFlightServerConfig';
41
42 -import {Dispatcher, getCurrentCache, setCurrentCache} from './ReactFlightHooks';
42 +import {
43 + Dispatcher,
44 + getCurrentCache,
45 + prepareToUseHooksForRequest,
46 + resetHooksForRequest,
47 + setCurrentCache,
48 +} from './ReactFlightHooks';
49 import {
50 pushProvider,
51 popProvider,
@@ -102,14 +108,12 @@ export type Request = {
108 writtenSymbols: Map<Symbol, number>,
109 writtenModules: Map<ModuleKey, number>,
110 writtenProviders: Map<string, number>,
111 + identifierPrefix: string,
112 + identifierCount: number,
113 onError: (error: mixed) => void,
114 toJSON: (key: string, value: ReactModel) => ReactJSONValue,
115 };
116
109 -export type Options = {
110 - onError?: (error: mixed) => void,
111 -};
112 -
117 const ReactCurrentDispatcher = ReactSharedInternals.ReactCurrentDispatcher;
118
119 function defaultErrorHandler(error: mixed) {
@@ -126,6 +130,7 @@ export function createRequest(
130 bundlerConfig: BundlerConfig,
131 onError: void | ((error: mixed) => void),
132 context?: Array<[string, ServerContextJSONValue]>,
133 + identifierPrefix?: string,
134 ): Request {
135 const pingedSegments = [];
136 const request = {
@@ -143,6 +148,8 @@ export function createRequest(
148 writtenSymbols: new Map(),
149 writtenModules: new Map(),
150 writtenProviders: new Map(),
151 + identifierPrefix: identifierPrefix || '',
152 + identifierCount: 1,
153 onError: onError === undefined ? defaultErrorHandler : onError,
154 toJSON: function(key: string, value: ReactModel): ReactJSONValue {
155 return resolveModelToJSON(request, this, key, value);
@@ -826,6 +833,7 @@ function performWork(request: Request): void {
833 const prevCache = getCurrentCache();
834 ReactCurrentDispatcher.current = Dispatcher;
835 setCurrentCache(request.cache);
836 + prepareToUseHooksForRequest(request);
837
838 try {
839 const pingedSegments = request.pingedSegments;
@@ -843,6 +851,7 @@ function performWork(request: Request): void {
851 } finally {
852 ReactCurrentDispatcher.current = prevDispatcher;
853 setCurrentCache(prevCache);
854 + resetHooksForRequest();
855 }
856 }
857
scripts/error-codes/codes.json
+2 -1
@@ -417,5 +417,6 @@
417 "429": "ServerContext: %s already defined",
418 "430": "ServerContext can only have a value prop and children. Found: %s",
419 "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"
422 }