mirror of
https://github.com/zebrajr/react.git
synced 2026-01-15 12:15:22 +00:00
[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
This commit is contained in:
@@ -512,6 +512,99 @@ describe('ReactFlight', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('Hooks', () => {
|
||||
function DivWithId({children}) {
|
||||
const id = React.useId();
|
||||
return <div prop={id}>{children}</div>;
|
||||
}
|
||||
|
||||
it('should support useId', () => {
|
||||
function App() {
|
||||
return (
|
||||
<>
|
||||
<DivWithId />
|
||||
<DivWithId />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const transport = ReactNoopFlightServer.render(<App />);
|
||||
act(() => {
|
||||
ReactNoop.render(ReactNoopFlightClient.read(transport));
|
||||
});
|
||||
expect(ReactNoop).toMatchRenderedOutput(
|
||||
<>
|
||||
<div prop=":S1:" />
|
||||
<div prop=":S2:" />
|
||||
</>,
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts an identifier prefix that prefixes generated ids', () => {
|
||||
function App() {
|
||||
return (
|
||||
<>
|
||||
<DivWithId />
|
||||
<DivWithId />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const transport = ReactNoopFlightServer.render(<App />, {
|
||||
identifierPrefix: 'foo',
|
||||
});
|
||||
act(() => {
|
||||
ReactNoop.render(ReactNoopFlightClient.read(transport));
|
||||
});
|
||||
expect(ReactNoop).toMatchRenderedOutput(
|
||||
<>
|
||||
<div prop=":fooS1:" />
|
||||
<div prop=":fooS2:" />
|
||||
</>,
|
||||
);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
// @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
|
||||
// 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
|
||||
// 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
|
||||
// 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
|
||||
// we just accept that it is a nuance of useId in Flight
|
||||
function App() {
|
||||
const id = React.useId();
|
||||
const div = <div prop={id}>{id}</div>;
|
||||
return <ClientDoublerModuleRef el={div} />;
|
||||
}
|
||||
|
||||
function ClientDoubler({el}) {
|
||||
Scheduler.unstable_yieldValue('ClientDoubler');
|
||||
return (
|
||||
<>
|
||||
{el}
|
||||
{el}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const ClientDoublerModuleRef = moduleReference(ClientDoubler);
|
||||
|
||||
const transport = ReactNoopFlightServer.render(<App />);
|
||||
expect(Scheduler).toHaveYielded([]);
|
||||
|
||||
act(() => {
|
||||
ReactNoop.render(ReactNoopFlightClient.read(transport));
|
||||
});
|
||||
|
||||
expect(Scheduler).toHaveYielded(['ClientDoubler']);
|
||||
expect(ReactNoop).toMatchRenderedOutput(
|
||||
<>
|
||||
<div prop=":S1:">:S1:</div>
|
||||
<div prop=":S1:">:S1:</div>
|
||||
</>,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ServerContext', () => {
|
||||
// @gate enableServerContext
|
||||
it('supports basic createServerContext usage', () => {
|
||||
@@ -759,15 +852,14 @@ describe('ReactFlight', () => {
|
||||
function Bar() {
|
||||
return <span>{React.useContext(ServerContext)}</span>;
|
||||
}
|
||||
const transport = ReactNoopFlightServer.render(<Bar />, {}, [
|
||||
['ServerContext', 'Override'],
|
||||
]);
|
||||
const transport = ReactNoopFlightServer.render(<Bar />, {
|
||||
context: [['ServerContext', 'Override']],
|
||||
});
|
||||
|
||||
act(() => {
|
||||
const flightModel = ReactNoopFlightClient.read(transport);
|
||||
ReactNoop.render(flightModel);
|
||||
});
|
||||
|
||||
expect(ReactNoop).toMatchRenderedOutput(<span>Override</span>);
|
||||
});
|
||||
|
||||
|
||||
@@ -61,20 +61,19 @@ const ReactNoopFlightServer = ReactFlightServer({
|
||||
|
||||
type Options = {
|
||||
onError?: (error: mixed) => void,
|
||||
context?: Array<[string, ServerContextJSONValue]>,
|
||||
identifierPrefix?: string,
|
||||
};
|
||||
|
||||
function render(
|
||||
model: ReactModel,
|
||||
options?: Options,
|
||||
context?: Array<[string, ServerContextJSONValue]>,
|
||||
): Destination {
|
||||
function render(model: ReactModel, options?: Options): Destination {
|
||||
const destination: Destination = [];
|
||||
const bundlerConfig = undefined;
|
||||
const request = ReactNoopFlightServer.createRequest(
|
||||
model,
|
||||
bundlerConfig,
|
||||
options ? options.onError : undefined,
|
||||
context,
|
||||
options ? options.context : undefined,
|
||||
options ? options.identifierPrefix : undefined,
|
||||
);
|
||||
ReactNoopFlightServer.startWork(request);
|
||||
ReactNoopFlightServer.startFlowing(request, destination);
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
|
||||
type Options = {
|
||||
onError?: (error: mixed) => void,
|
||||
identifierPrefix?: string,
|
||||
};
|
||||
|
||||
function render(
|
||||
@@ -33,6 +34,8 @@ function render(
|
||||
model,
|
||||
config,
|
||||
options ? options.onError : undefined,
|
||||
undefined, // not currently set up to supply context overrides
|
||||
options ? options.identifierPrefix : undefined,
|
||||
);
|
||||
startWork(request);
|
||||
startFlowing(request, destination);
|
||||
|
||||
@@ -19,19 +19,21 @@ import {
|
||||
|
||||
type Options = {
|
||||
onError?: (error: mixed) => void,
|
||||
context?: Array<[string, ServerContextJSONValue]>,
|
||||
identifierPrefix?: string,
|
||||
};
|
||||
|
||||
function renderToReadableStream(
|
||||
model: ReactModel,
|
||||
webpackMap: BundlerConfig,
|
||||
options?: Options,
|
||||
context?: Array<[string, ServerContextJSONValue]>,
|
||||
): ReadableStream {
|
||||
const request = createRequest(
|
||||
model,
|
||||
webpackMap,
|
||||
options ? options.onError : undefined,
|
||||
context,
|
||||
options ? options.context : undefined,
|
||||
options ? options.identifierPrefix : undefined,
|
||||
);
|
||||
const stream = new ReadableStream(
|
||||
{
|
||||
|
||||
@@ -24,6 +24,8 @@ function createDrainHandler(destination, request) {
|
||||
|
||||
type Options = {
|
||||
onError?: (error: mixed) => void,
|
||||
context?: Array<[string, ServerContextJSONValue]>,
|
||||
identifierPrefix?: string,
|
||||
};
|
||||
|
||||
type PipeableStream = {|
|
||||
@@ -40,7 +42,8 @@ function renderToPipeableStream(
|
||||
model,
|
||||
webpackMap,
|
||||
options ? options.onError : undefined,
|
||||
context,
|
||||
options ? options.context : undefined,
|
||||
options ? options.identifierPrefix : undefined,
|
||||
);
|
||||
let hasStartedFlowing = false;
|
||||
startWork(request);
|
||||
|
||||
22
packages/react-server/src/ReactFlightHooks.js
vendored
22
packages/react-server/src/ReactFlightHooks.js
vendored
@@ -8,10 +8,21 @@
|
||||
*/
|
||||
|
||||
import type {Dispatcher as DispatcherType} from 'react-reconciler/src/ReactInternalTypes';
|
||||
import type {Request} from './ReactFlightServer';
|
||||
import type {ReactServerContext} from 'shared/ReactTypes';
|
||||
import {REACT_SERVER_CONTEXT_TYPE} from 'shared/ReactSymbols';
|
||||
import {readContext as readContextImpl} from './ReactFlightNewContext';
|
||||
|
||||
let currentRequest = null;
|
||||
|
||||
export function prepareToUseHooksForRequest(request: Request) {
|
||||
currentRequest = request;
|
||||
}
|
||||
|
||||
export function resetHooksForRequest() {
|
||||
currentRequest = null;
|
||||
}
|
||||
|
||||
function readContext<T>(context: ReactServerContext<T>): T {
|
||||
if (__DEV__) {
|
||||
if (context.$$typeof !== REACT_SERVER_CONTEXT_TYPE) {
|
||||
@@ -61,7 +72,7 @@ export const Dispatcher: DispatcherType = {
|
||||
useLayoutEffect: (unsupportedHook: any),
|
||||
useImperativeHandle: (unsupportedHook: any),
|
||||
useEffect: (unsupportedHook: any),
|
||||
useId: (unsupportedHook: any),
|
||||
useId,
|
||||
useMutableSource: (unsupportedHook: any),
|
||||
useSyncExternalStore: (unsupportedHook: any),
|
||||
useCacheRefresh(): <T>(?() => T, ?T) => void {
|
||||
@@ -91,3 +102,12 @@ export function setCurrentCache(cache: Map<Function, mixed> | null) {
|
||||
export function getCurrentCache() {
|
||||
return currentCache;
|
||||
}
|
||||
|
||||
function useId(): string {
|
||||
if (currentRequest === null) {
|
||||
throw new Error('useId can only be used while React is rendering');
|
||||
}
|
||||
const id = currentRequest.identifierCount++;
|
||||
// use 'S' for Flight components to distinguish from 'R' and 'r' in Fizz/Client
|
||||
return ':' + currentRequest.identifierPrefix + 'S' + id.toString(32) + ':';
|
||||
}
|
||||
|
||||
19
packages/react-server/src/ReactFlightServer.js
vendored
19
packages/react-server/src/ReactFlightServer.js
vendored
@@ -39,7 +39,13 @@ import {
|
||||
isModuleReference,
|
||||
} from './ReactFlightServerConfig';
|
||||
|
||||
import {Dispatcher, getCurrentCache, setCurrentCache} from './ReactFlightHooks';
|
||||
import {
|
||||
Dispatcher,
|
||||
getCurrentCache,
|
||||
prepareToUseHooksForRequest,
|
||||
resetHooksForRequest,
|
||||
setCurrentCache,
|
||||
} from './ReactFlightHooks';
|
||||
import {
|
||||
pushProvider,
|
||||
popProvider,
|
||||
@@ -102,14 +108,12 @@ export type Request = {
|
||||
writtenSymbols: Map<Symbol, number>,
|
||||
writtenModules: Map<ModuleKey, number>,
|
||||
writtenProviders: Map<string, number>,
|
||||
identifierPrefix: string,
|
||||
identifierCount: number,
|
||||
onError: (error: mixed) => void,
|
||||
toJSON: (key: string, value: ReactModel) => ReactJSONValue,
|
||||
};
|
||||
|
||||
export type Options = {
|
||||
onError?: (error: mixed) => void,
|
||||
};
|
||||
|
||||
const ReactCurrentDispatcher = ReactSharedInternals.ReactCurrentDispatcher;
|
||||
|
||||
function defaultErrorHandler(error: mixed) {
|
||||
@@ -126,6 +130,7 @@ export function createRequest(
|
||||
bundlerConfig: BundlerConfig,
|
||||
onError: void | ((error: mixed) => void),
|
||||
context?: Array<[string, ServerContextJSONValue]>,
|
||||
identifierPrefix?: string,
|
||||
): Request {
|
||||
const pingedSegments = [];
|
||||
const request = {
|
||||
@@ -143,6 +148,8 @@ export function createRequest(
|
||||
writtenSymbols: new Map(),
|
||||
writtenModules: new Map(),
|
||||
writtenProviders: new Map(),
|
||||
identifierPrefix: identifierPrefix || '',
|
||||
identifierCount: 1,
|
||||
onError: onError === undefined ? defaultErrorHandler : onError,
|
||||
toJSON: function(key: string, value: ReactModel): ReactJSONValue {
|
||||
return resolveModelToJSON(request, this, key, value);
|
||||
@@ -826,6 +833,7 @@ function performWork(request: Request): void {
|
||||
const prevCache = getCurrentCache();
|
||||
ReactCurrentDispatcher.current = Dispatcher;
|
||||
setCurrentCache(request.cache);
|
||||
prepareToUseHooksForRequest(request);
|
||||
|
||||
try {
|
||||
const pingedSegments = request.pingedSegments;
|
||||
@@ -843,6 +851,7 @@ function performWork(request: Request): void {
|
||||
} finally {
|
||||
ReactCurrentDispatcher.current = prevDispatcher;
|
||||
setCurrentCache(prevCache);
|
||||
resetHooksForRequest();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -417,5 +417,6 @@
|
||||
"429": "ServerContext: %s already defined",
|
||||
"430": "ServerContext can only have a value prop and children. Found: %s",
|
||||
"431": "React elements are not allowed in ServerContext",
|
||||
"432": "This Suspense boundary was aborted by the server"
|
||||
"432": "This Suspense boundary was aborted by the server",
|
||||
"433": "useId can only be used while React is rendering"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user