Files
react/packages/react-client/src/__tests__/ReactFlight-test.js
Sebastian Markbåge 4ea424e63d Capitalize the default Flight environment (#28402)
It's cleaner and more in line with how we style other badges like "Memo"
and "ForwardRef" in DevTools.
2024-02-20 22:30:23 -05:00

1999 lines
57 KiB
JavaScript

/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @emails react-core
* @jest-environment node
*/
'use strict';
const heldValues = [];
let finalizationCallback;
function FinalizationRegistryMock(callback) {
finalizationCallback = callback;
}
FinalizationRegistryMock.prototype.register = function (target, heldValue) {
heldValues.push(heldValue);
};
global.FinalizationRegistry = FinalizationRegistryMock;
function gc() {
for (let i = 0; i < heldValues.length; i++) {
finalizationCallback(heldValues[i]);
}
heldValues.length = 0;
}
let act;
let use;
let startTransition;
let React;
let ReactServer;
let ReactNoop;
let ReactNoopFlightServer;
let ReactNoopFlightClient;
let ErrorBoundary;
let NoErrorExpected;
let Scheduler;
let assertLog;
describe('ReactFlight', () => {
beforeEach(() => {
jest.resetModules();
jest.mock('react', () => require('react/react.react-server'));
ReactServer = require('react');
ReactNoopFlightServer = require('react-noop-renderer/flight-server');
// This stores the state so we need to preserve it
const flightModules = require('react-noop-renderer/flight-modules');
__unmockReact();
jest.resetModules();
jest.mock('react-noop-renderer/flight-modules', () => flightModules);
React = require('react');
startTransition = React.startTransition;
use = React.use;
ReactNoop = require('react-noop-renderer');
ReactNoopFlightClient = require('react-noop-renderer/flight-client');
act = require('internal-test-utils').act;
Scheduler = require('scheduler');
const InternalTestUtils = require('internal-test-utils');
assertLog = InternalTestUtils.assertLog;
ErrorBoundary = class extends React.Component {
state = {hasError: false, error: null};
static getDerivedStateFromError(error) {
return {
hasError: true,
error,
};
}
componentDidMount() {
expect(this.state.hasError).toBe(true);
expect(this.state.error).toBeTruthy();
if (__DEV__) {
expect(this.state.error.message).toContain(
this.props.expectedMessage,
);
expect(this.state.error.digest).toBe('a dev digest');
} else {
expect(this.state.error.message).toBe(
'An error occurred in the Server Components render. The specific message is omitted in production' +
' builds to avoid leaking sensitive details. A digest property is included on this error instance which' +
' may provide additional details about the nature of the error.',
);
let expectedDigest = this.props.expectedMessage;
if (
expectedDigest.startsWith('{') ||
expectedDigest.startsWith('<')
) {
expectedDigest = '{}';
} else if (expectedDigest.startsWith('[')) {
expectedDigest = '[]';
}
expect(this.state.error.digest).toContain(expectedDigest);
expect(this.state.error.stack).toBe(
'Error: ' + this.state.error.message,
);
}
}
render() {
if (this.state.hasError) {
return this.state.error.message;
}
return this.props.children;
}
};
NoErrorExpected = class extends React.Component {
state = {hasError: false, error: null};
static getDerivedStateFromError(error) {
return {
hasError: true,
error,
};
}
componentDidMount() {
expect(this.state.error).toBe(null);
expect(this.state.hasError).toBe(false);
}
render() {
if (this.state.hasError) {
return this.state.error.message;
}
return this.props.children;
}
};
});
afterEach(() => {
jest.restoreAllMocks();
});
function clientReference(value) {
return Object.defineProperties(
function () {
throw new Error('Cannot call a client function from the server.');
},
{
$$typeof: {value: Symbol.for('react.client.reference')},
value: {value: value},
},
);
}
it('can render a Server Component', async () => {
function Bar({text}) {
return text.toUpperCase();
}
function Foo() {
return {
bar: (
<div>
<Bar text="a" />, <Bar text="b" />
</div>
),
};
}
const transport = ReactNoopFlightServer.render({
foo: <Foo />,
});
const model = await ReactNoopFlightClient.read(transport);
expect(model).toEqual({
foo: {
bar: (
<div>
{'A'}
{', '}
{'B'}
</div>
),
},
});
});
it('can render a Client Component using a module reference and render there', async () => {
function UserClient(props) {
return (
<span>
{props.greeting}, {props.name}
</span>
);
}
const User = clientReference(UserClient);
function Greeting({firstName, lastName}) {
return <User greeting="Hello" name={firstName + ' ' + lastName} />;
}
const model = {
greeting: <Greeting firstName="Seb" lastName="Smith" />,
};
const transport = ReactNoopFlightServer.render(model);
await act(async () => {
const rootModel = await ReactNoopFlightClient.read(transport);
const greeting = rootModel.greeting;
expect(greeting._debugInfo).toEqual(
__DEV__ ? [{name: 'Greeting', env: 'Server'}] : undefined,
);
ReactNoop.render(greeting);
});
expect(ReactNoop).toMatchRenderedOutput(<span>Hello, Seb Smith</span>);
});
it('can render a shared forwardRef Component', async () => {
const Greeting = React.forwardRef(function Greeting(
{firstName, lastName},
ref,
) {
return (
<span ref={ref}>
Hello, {firstName} {lastName}
</span>
);
});
const root = <Greeting firstName="Seb" lastName="Smith" />;
const transport = ReactNoopFlightServer.render(root);
await act(async () => {
const promise = ReactNoopFlightClient.read(transport);
expect(promise._debugInfo).toEqual(
__DEV__ ? [{name: 'Greeting', env: 'Server'}] : undefined,
);
ReactNoop.render(await promise);
});
expect(ReactNoop).toMatchRenderedOutput(<span>Hello, Seb Smith</span>);
});
it('can render an iterable as an array', async () => {
function ItemListClient(props) {
return <span>{props.items}</span>;
}
const ItemList = clientReference(ItemListClient);
function Items() {
const iterable = {
[Symbol.iterator]: function* () {
yield 'A';
yield 'B';
yield 'C';
},
};
return <ItemList items={iterable} />;
}
const model = <Items />;
const transport = ReactNoopFlightServer.render(model);
await act(async () => {
ReactNoop.render(await ReactNoopFlightClient.read(transport));
});
expect(ReactNoop).toMatchRenderedOutput(<span>ABC</span>);
});
it('can render undefined', async () => {
function Undefined() {
return undefined;
}
const model = <Undefined />;
const transport = ReactNoopFlightServer.render(model);
await act(async () => {
ReactNoop.render(await ReactNoopFlightClient.read(transport));
});
expect(ReactNoop).toMatchRenderedOutput(null);
});
// @gate FIXME
it('should transport undefined object values', async () => {
function ServerComponent(props) {
return 'prop' in props
? `\`prop\` in props as '${props.prop}'`
: '`prop` not in props';
}
const ClientComponent = clientReference(ServerComponent);
const model = (
<>
<div>
Server: <ServerComponent prop={undefined} />
</div>
<div>
Client: <ClientComponent prop={undefined} />
</div>
</>
);
const transport = ReactNoopFlightServer.render(model);
await act(async () => {
ReactNoop.render(await ReactNoopFlightClient.read(transport));
});
expect(ReactNoop).toMatchRenderedOutput(
<>
<div>Server: `prop` in props as 'undefined'</div>
<div>Client: `prop` in props as 'undefined'</div>
</>,
);
});
it('can render an empty fragment', async () => {
function Empty() {
return <React.Fragment />;
}
const model = <Empty />;
const transport = ReactNoopFlightServer.render(model);
await act(async () => {
ReactNoop.render(await ReactNoopFlightClient.read(transport));
});
expect(ReactNoop).toMatchRenderedOutput(null);
});
it('can transport weird numbers', async () => {
const nums = [0, -0, Infinity, -Infinity, NaN];
function ComponentClient({prop}) {
expect(prop).not.toBe(nums);
expect(prop).toEqual(nums);
expect(prop.every((p, i) => Object.is(p, nums[i]))).toBe(true);
return `prop: ${prop}`;
}
const Component = clientReference(ComponentClient);
const model = <Component prop={nums} />;
const transport = ReactNoopFlightServer.render(model);
await act(async () => {
ReactNoop.render(await ReactNoopFlightClient.read(transport));
});
expect(ReactNoop).toMatchRenderedOutput(
// already checked -0 with expects above
'prop: 0,0,Infinity,-Infinity,NaN',
);
});
it('can transport BigInt', async () => {
function ComponentClient({prop}) {
return `prop: ${prop} (${typeof prop})`;
}
const Component = clientReference(ComponentClient);
const model = <Component prop={90071992547409910000n} />;
const transport = ReactNoopFlightServer.render(model);
await act(async () => {
ReactNoop.render(await ReactNoopFlightClient.read(transport));
});
expect(ReactNoop).toMatchRenderedOutput(
'prop: 90071992547409910000 (bigint)',
);
});
it('can transport Date', async () => {
function ComponentClient({prop}) {
return `prop: ${prop.toISOString()}`;
}
const Component = clientReference(ComponentClient);
const model = <Component prop={new Date(1234567890123)} />;
const transport = ReactNoopFlightServer.render(model);
await act(async () => {
ReactNoop.render(await ReactNoopFlightClient.read(transport));
});
expect(ReactNoop).toMatchRenderedOutput('prop: 2009-02-13T23:31:30.123Z');
});
it('can transport Map', async () => {
function ComponentClient({prop, selected}) {
return `
map: ${prop instanceof Map}
size: ${prop.size}
greet: ${prop.get('hi').greet}
content: ${JSON.stringify(Array.from(prop))}
selected: ${prop.get(selected)}
`;
}
const Component = clientReference(ComponentClient);
const objKey = {obj: 'key'};
const map = new Map([
['hi', {greet: 'world'}],
[objKey, 123],
]);
const model = <Component prop={map} selected={objKey} />;
const transport = ReactNoopFlightServer.render(model);
await act(async () => {
ReactNoop.render(await ReactNoopFlightClient.read(transport));
});
expect(ReactNoop).toMatchRenderedOutput(`
map: true
size: 2
greet: world
content: [["hi",{"greet":"world"}],[{"obj":"key"},123]]
selected: 123
`);
});
it('can transport Set', async () => {
function ComponentClient({prop, selected}) {
return `
set: ${prop instanceof Set}
size: ${prop.size}
hi: ${prop.has('hi')}
content: ${JSON.stringify(Array.from(prop))}
selected: ${prop.has(selected)}
`;
}
const Component = clientReference(ComponentClient);
const objKey = {obj: 'key'};
const set = new Set(['hi', objKey]);
const model = <Component prop={set} selected={objKey} />;
const transport = ReactNoopFlightServer.render(model);
await act(async () => {
ReactNoop.render(await ReactNoopFlightClient.read(transport));
});
expect(ReactNoop).toMatchRenderedOutput(`
set: true
size: 2
hi: true
content: ["hi",{"obj":"key"}]
selected: true
`);
});
it('can transport cyclic objects', async () => {
function ComponentClient({prop}) {
expect(prop.obj.obj.obj).toBe(prop.obj.obj);
}
const Component = clientReference(ComponentClient);
const cyclic = {obj: null};
cyclic.obj = cyclic;
const model = <Component prop={cyclic} />;
const transport = ReactNoopFlightServer.render(model);
await act(async () => {
ReactNoop.render(await ReactNoopFlightClient.read(transport));
});
});
it('can render a lazy component as a shared component on the server', async () => {
function SharedComponent({text}) {
return (
<div>
shared<span>{text}</span>
</div>
);
}
let load = null;
const loadSharedComponent = () => {
return new Promise(res => {
load = () => res({default: SharedComponent});
});
};
const LazySharedComponent = React.lazy(loadSharedComponent);
function ServerComponent() {
return (
<React.Suspense fallback={'Loading...'}>
<LazySharedComponent text={'a'} />
</React.Suspense>
);
}
const transport = ReactNoopFlightServer.render(<ServerComponent />);
await act(async () => {
const rootModel = await ReactNoopFlightClient.read(transport);
ReactNoop.render(rootModel);
});
expect(ReactNoop).toMatchRenderedOutput('Loading...');
await load();
await act(async () => {
const rootModel = await ReactNoopFlightClient.read(transport);
ReactNoop.render(rootModel);
});
expect(ReactNoop).toMatchRenderedOutput(
<div>
shared<span>a</span>
</div>,
);
});
it('errors on a Lazy element being used in Component position', async () => {
function SharedComponent({text}) {
return (
<div>
shared<span>{text}</span>
</div>
);
}
let load = null;
const LazyElementDisguisedAsComponent = React.lazy(() => {
return new Promise(res => {
load = () => res({default: <SharedComponent text={'a'} />});
});
});
function ServerComponent() {
return (
<React.Suspense fallback={'Loading...'}>
<LazyElementDisguisedAsComponent text={'b'} />
</React.Suspense>
);
}
const transport = ReactNoopFlightServer.render(<ServerComponent />);
await act(async () => {
const rootModel = await ReactNoopFlightClient.read(transport);
ReactNoop.render(rootModel);
});
expect(ReactNoop).toMatchRenderedOutput('Loading...');
spyOnDevAndProd(console, 'error').mockImplementation(() => {});
await load();
expect(console.error).toHaveBeenCalledTimes(1);
});
it('can render a lazy element', async () => {
function SharedComponent({text}) {
return (
<div>
shared<span>{text}</span>
</div>
);
}
let load = null;
const lazySharedElement = React.lazy(() => {
return new Promise(res => {
load = () => res({default: <SharedComponent text={'a'} />});
});
});
function ServerComponent() {
return (
<React.Suspense fallback={'Loading...'}>
{lazySharedElement}
</React.Suspense>
);
}
const transport = ReactNoopFlightServer.render(<ServerComponent />);
await act(async () => {
const rootModel = await ReactNoopFlightClient.read(transport);
ReactNoop.render(rootModel);
});
expect(ReactNoop).toMatchRenderedOutput('Loading...');
await load();
await act(async () => {
const rootModel = await ReactNoopFlightClient.read(transport);
ReactNoop.render(rootModel);
});
expect(ReactNoop).toMatchRenderedOutput(
<div>
shared<span>a</span>
</div>,
);
});
it('errors with lazy value in element position that resolves to Component', async () => {
function SharedComponent({text}) {
return (
<div>
shared<span>{text}</span>
</div>
);
}
let load = null;
const componentDisguisedAsElement = React.lazy(() => {
return new Promise(res => {
load = () => res({default: SharedComponent});
});
});
function ServerComponent() {
return (
<React.Suspense fallback={'Loading...'}>
{componentDisguisedAsElement}
</React.Suspense>
);
}
const transport = ReactNoopFlightServer.render(<ServerComponent />);
await act(async () => {
const rootModel = await ReactNoopFlightClient.read(transport);
ReactNoop.render(rootModel);
});
expect(ReactNoop).toMatchRenderedOutput('Loading...');
spyOnDevAndProd(console, 'error').mockImplementation(() => {});
await load();
expect(console.error).toHaveBeenCalledTimes(1);
});
it('can render a lazy module reference', async () => {
function ClientComponent() {
return <div>I am client</div>;
}
const ClientComponentReference = clientReference(ClientComponent);
let load = null;
const loadClientComponentReference = () => {
return new Promise(res => {
load = () => res({default: ClientComponentReference});
});
};
const LazyClientComponentReference = React.lazy(
loadClientComponentReference,
);
function ServerComponent() {
return (
<React.Suspense fallback={'Loading...'}>
<LazyClientComponentReference />
</React.Suspense>
);
}
const transport = ReactNoopFlightServer.render(<ServerComponent />);
await act(async () => {
const rootModel = await ReactNoopFlightClient.read(transport);
ReactNoop.render(rootModel);
});
expect(ReactNoop).toMatchRenderedOutput('Loading...');
await load();
await act(async () => {
const rootModel = await ReactNoopFlightClient.read(transport);
ReactNoop.render(rootModel);
});
expect(ReactNoop).toMatchRenderedOutput(<div>I am client</div>);
});
it('should error if a non-serializable value is passed to a host component', async () => {
function ClientImpl({children}) {
return children;
}
const Client = clientReference(ClientImpl);
function EventHandlerProp() {
return (
<div className="foo" onClick={function () {}}>
Test
</div>
);
}
function FunctionProp() {
return <div>{function fn() {}}</div>;
}
function SymbolProp() {
return <div foo={Symbol('foo')} />;
}
const ref = React.createRef();
function RefProp() {
return <div ref={ref} />;
}
function EventHandlerPropClient() {
return (
<Client className="foo" onClick={function () {}}>
Test
</Client>
);
}
function FunctionChildrenClient() {
return <Client>{function Component() {}}</Client>;
}
function FunctionPropClient() {
return <Client foo={() => {}} />;
}
function SymbolPropClient() {
return <Client foo={Symbol('foo')} />;
}
function RefPropClient() {
return <Client ref={ref} />;
}
const options = {
onError(x) {
return __DEV__ ? 'a dev digest' : `digest("${x.message}")`;
},
};
const event = ReactNoopFlightServer.render(<EventHandlerProp />, options);
const fn = ReactNoopFlightServer.render(<FunctionProp />, options);
const symbol = ReactNoopFlightServer.render(<SymbolProp />, options);
const refs = ReactNoopFlightServer.render(<RefProp />, options);
const eventClient = ReactNoopFlightServer.render(
<EventHandlerPropClient />,
options,
);
const fnChildrenClient = ReactNoopFlightServer.render(
<FunctionChildrenClient />,
options,
);
const fnClient = ReactNoopFlightServer.render(
<FunctionPropClient />,
options,
);
const symbolClient = ReactNoopFlightServer.render(
<SymbolPropClient />,
options,
);
const refsClient = ReactNoopFlightServer.render(<RefPropClient />, options);
function Render({promise}) {
return use(promise);
}
await act(() => {
startTransition(() => {
ReactNoop.render(
<>
<ErrorBoundary expectedMessage="Event handlers cannot be passed to Client Component props.">
<Render promise={ReactNoopFlightClient.read(event)} />
</ErrorBoundary>
<ErrorBoundary
expectedMessage={
__DEV__
? 'Functions are not valid as a child of Client Components. This may happen if you return fn instead of <fn /> from render. Or maybe you meant to call this function rather than return it.'
: 'Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server".'
}>
<Render promise={ReactNoopFlightClient.read(fn)} />
</ErrorBoundary>
<ErrorBoundary expectedMessage="Only global symbols received from Symbol.for(...) can be passed to Client Components.">
<Render promise={ReactNoopFlightClient.read(symbol)} />
</ErrorBoundary>
<ErrorBoundary expectedMessage="Refs cannot be used in Server Components, nor passed to Client Components.">
<Render promise={ReactNoopFlightClient.read(refs)} />
</ErrorBoundary>
<ErrorBoundary expectedMessage="Event handlers cannot be passed to Client Component props.">
<Render promise={ReactNoopFlightClient.read(eventClient)} />
</ErrorBoundary>
<ErrorBoundary
expectedMessage={
__DEV__
? 'Functions are not valid as a child of Client Components. This may happen if you return Component instead of <Component /> from render. Or maybe you meant to call this function rather than return it.'
: 'Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server".'
}>
<Render promise={ReactNoopFlightClient.read(fnChildrenClient)} />
</ErrorBoundary>
<ErrorBoundary
expectedMessage={
'Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server".'
}>
<Render promise={ReactNoopFlightClient.read(fnClient)} />
</ErrorBoundary>
<ErrorBoundary expectedMessage="Only global symbols received from Symbol.for(...) can be passed to Client Components.">
<Render promise={ReactNoopFlightClient.read(symbolClient)} />
</ErrorBoundary>
<ErrorBoundary expectedMessage="Refs cannot be used in Server Components, nor passed to Client Components.">
<Render promise={ReactNoopFlightClient.read(refsClient)} />
</ErrorBoundary>
</>,
);
});
});
});
it('should emit descriptions of errors in dev', async () => {
const ClientErrorBoundary = clientReference(ErrorBoundary);
function Throw({value}) {
throw value;
}
const testCases = (
<>
<ClientErrorBoundary expectedMessage="This is a real Error.">
<div>
<Throw value={new TypeError('This is a real Error.')} />
</div>
</ClientErrorBoundary>
<ClientErrorBoundary expectedMessage="This is a string error.">
<div>
<Throw value="This is a string error." />
</div>
</ClientErrorBoundary>
<ClientErrorBoundary expectedMessage="{message: ..., extra: ..., nested: ...}">
<div>
<Throw
value={{
message: 'This is a long message',
extra: 'properties',
nested: {more: 'prop'},
}}
/>
</div>
</ClientErrorBoundary>
<ClientErrorBoundary
expectedMessage={'{message: "Short", extra: ..., nested: ...}'}>
<div>
<Throw
value={{
message: 'Short',
extra: 'properties',
nested: {more: 'prop'},
}}
/>
</div>
</ClientErrorBoundary>
<ClientErrorBoundary expectedMessage="Symbol(hello)">
<div>
<Throw value={Symbol('hello')} />
</div>
</ClientErrorBoundary>
<ClientErrorBoundary expectedMessage="123">
<div>
<Throw value={123} />
</div>
</ClientErrorBoundary>
<ClientErrorBoundary expectedMessage="undefined">
<div>
<Throw value={undefined} />
</div>
</ClientErrorBoundary>
<ClientErrorBoundary expectedMessage="<div/>">
<div>
<Throw value={<div />} />
</div>
</ClientErrorBoundary>
<ClientErrorBoundary expectedMessage="function Foo() {}">
<div>
<Throw value={function Foo() {}} />
</div>
</ClientErrorBoundary>
<ClientErrorBoundary expectedMessage={'["array"]'}>
<div>
<Throw value={['array']} />
</div>
</ClientErrorBoundary>
</>
);
const transport = ReactNoopFlightServer.render(testCases, {
onError(x) {
if (__DEV__) {
return 'a dev digest';
}
if (x instanceof Error) {
return `digest("${x.message}")`;
} else if (Array.isArray(x)) {
return `digest([])`;
} else if (typeof x === 'object' && x !== null) {
return `digest({})`;
}
return `digest(${String(x)})`;
},
});
await act(() => {
startTransition(() => {
ReactNoop.render(ReactNoopFlightClient.read(transport));
});
});
});
it('should trigger the inner most error boundary inside a Client Component', async () => {
function ServerComponent() {
throw new Error('This was thrown in the Server Component.');
}
function ClientComponent({children}) {
// This should catch the error thrown by the Server Component, even though it has already happened.
// We currently need to wrap it in a div because as it's set up right now, a lazy reference will
// throw during reconciliation which will trigger the parent of the error boundary.
// This is similar to how these will suspend the parent if it's a direct child of a Suspense boundary.
// That's a bug.
return (
<ErrorBoundary expectedMessage="This was thrown in the Server Component.">
<div>{children}</div>
</ErrorBoundary>
);
}
const ClientComponentReference = clientReference(ClientComponent);
function Server() {
return (
<ClientComponentReference>
<ServerComponent />
</ClientComponentReference>
);
}
const data = ReactNoopFlightServer.render(<Server />, {
onError(x) {
// ignore
},
});
function Client({promise}) {
return use(promise);
}
await act(() => {
startTransition(() => {
ReactNoop.render(
<NoErrorExpected>
<Client promise={ReactNoopFlightClient.read(data)} />
</NoErrorExpected>,
);
});
});
});
it('should warn in DEV if a toJSON instance is passed to a host component', () => {
const obj = {
toJSON() {
return 123;
},
};
expect(() => {
const transport = ReactNoopFlightServer.render(<input value={obj} />);
ReactNoopFlightClient.read(transport);
}).toErrorDev(
'Only plain objects can be passed to Client Components from Server Components. ' +
'Objects with toJSON methods are not supported. ' +
'Convert it manually to a simple value before passing it to props.\n' +
' <input value={{toJSON: ...}}>\n' +
' ^^^^^^^^^^^^^^^',
{withoutStack: true},
);
});
it('should warn in DEV if a toJSON instance is passed to a host component child', () => {
class MyError extends Error {
toJSON() {
return 123;
}
}
expect(() => {
const transport = ReactNoopFlightServer.render(
<div>Womp womp: {new MyError('spaghetti')}</div>,
);
ReactNoopFlightClient.read(transport);
}).toErrorDev(
'Error objects cannot be rendered as text children. Try formatting it using toString().\n' +
' <div>Womp womp: {Error}</div>\n' +
' ^^^^^^^',
{withoutStack: true},
);
});
it('should warn in DEV if a special object is passed to a host component', () => {
expect(() => {
const transport = ReactNoopFlightServer.render(<input value={Math} />);
ReactNoopFlightClient.read(transport);
}).toErrorDev(
'Only plain objects can be passed to Client Components from Server Components. ' +
'Math objects are not supported.\n' +
' <input value={Math}>\n' +
' ^^^^^^',
{withoutStack: true},
);
});
it('should warn in DEV if an object with symbols is passed to a host component', () => {
expect(() => {
const transport = ReactNoopFlightServer.render(
<input value={{[Symbol.iterator]: {}}} />,
);
ReactNoopFlightClient.read(transport);
}).toErrorDev(
'Only plain objects can be passed to Client Components from Server Components. ' +
'Objects with symbol properties like Symbol.iterator are not supported.',
{withoutStack: true},
);
});
it('should warn in DEV if a toJSON instance is passed to a Client Component', () => {
const obj = {
toJSON() {
return 123;
},
};
function ClientImpl({value}) {
return <div>{value}</div>;
}
const Client = clientReference(ClientImpl);
expect(() => {
const transport = ReactNoopFlightServer.render(<Client value={obj} />);
ReactNoopFlightClient.read(transport);
}).toErrorDev(
'Only plain objects can be passed to Client Components from Server Components. ' +
'Objects with toJSON methods are not supported.',
{withoutStack: true},
);
});
it('should warn in DEV if a toJSON instance is passed to a Client Component child', () => {
const obj = {
toJSON() {
return 123;
},
};
function ClientImpl({children}) {
return <div>{children}</div>;
}
const Client = clientReference(ClientImpl);
expect(() => {
const transport = ReactNoopFlightServer.render(
<Client>Current date: {obj}</Client>,
);
ReactNoopFlightClient.read(transport);
}).toErrorDev(
'Only plain objects can be passed to Client Components from Server Components. ' +
'Objects with toJSON methods are not supported. ' +
'Convert it manually to a simple value before passing it to props.\n' +
' <>Current date: {{toJSON: ...}}</>\n' +
' ^^^^^^^^^^^^^^^',
{withoutStack: true},
);
});
it('should warn in DEV if a special object is passed to a Client Component', () => {
function ClientImpl({value}) {
return <div>{value}</div>;
}
const Client = clientReference(ClientImpl);
expect(() => {
const transport = ReactNoopFlightServer.render(<Client value={Math} />);
ReactNoopFlightClient.read(transport);
}).toErrorDev(
'Only plain objects can be passed to Client Components from Server Components. ' +
'Math objects are not supported.\n' +
' <... value={Math}>\n' +
' ^^^^^^',
{withoutStack: true},
);
});
it('should warn in DEV if an object with symbols is passed to a Client Component', () => {
function ClientImpl({value}) {
return <div>{value}</div>;
}
const Client = clientReference(ClientImpl);
expect(() => {
const transport = ReactNoopFlightServer.render(
<Client value={{[Symbol.iterator]: {}}} />,
);
ReactNoopFlightClient.read(transport);
}).toErrorDev(
'Only plain objects can be passed to Client Components from Server Components. ' +
'Objects with symbol properties like Symbol.iterator are not supported.',
{withoutStack: true},
);
});
it('should warn in DEV if a special object is passed to a nested object in Client Component', () => {
function ClientImpl({value}) {
return <div>{value}</div>;
}
const Client = clientReference(ClientImpl);
expect(() => {
const transport = ReactNoopFlightServer.render(
<Client value={{hello: Math, title: <h1>hi</h1>}} />,
);
ReactNoopFlightClient.read(transport);
}).toErrorDev(
'Only plain objects can be passed to Client Components from Server Components. ' +
'Math objects are not supported.\n' +
' {hello: Math, title: <h1/>}\n' +
' ^^^^',
{withoutStack: true},
);
});
it('should warn in DEV if a special object is passed to a nested array in Client Component', () => {
function ClientImpl({value}) {
return <div>{value}</div>;
}
const Client = clientReference(ClientImpl);
expect(() => {
const transport = ReactNoopFlightServer.render(
<Client
value={['looooong string takes up noise', Math, <h1>hi</h1>]}
/>,
);
ReactNoopFlightClient.read(transport);
}).toErrorDev(
'Only plain objects can be passed to Client Components from Server Components. ' +
'Math objects are not supported.\n' +
' [..., Math, <h1/>]\n' +
' ^^^^',
{withoutStack: true},
);
});
it('should NOT warn in DEV for key getters', () => {
const transport = ReactNoopFlightServer.render(<div key="a" />);
ReactNoopFlightClient.read(transport);
});
it('should warn in DEV a child is missing keys', () => {
function ParentClient({children}) {
return children;
}
const Parent = clientReference(ParentClient);
expect(() => {
const transport = ReactNoopFlightServer.render(
<Parent>{Array(6).fill(<div>no key</div>)}</Parent>,
);
ReactNoopFlightClient.read(transport);
}).toErrorDev(
'Each child in a list should have a unique "key" prop. ' +
'See https://reactjs.org/link/warning-keys for more information.',
);
});
it('should error if a class instance is passed to a host component', () => {
class Foo {
method() {}
}
const errors = [];
ReactNoopFlightServer.render(<input value={new Foo()} />, {
onError(x) {
errors.push(x.message);
},
});
expect(errors).toEqual([
'Only plain objects, and a few built-ins, can be passed to Client Components ' +
'from Server Components. Classes or null prototypes are not supported.',
]);
});
it('should error if useContext is called()', () => {
function ServerComponent() {
return ReactServer.useContext();
}
const errors = [];
ReactNoopFlightServer.render(<ServerComponent />, {
onError(x) {
errors.push(x.message);
},
});
expect(errors).toEqual(['ReactServer.useContext is not a function']);
});
it('should error if a context without a client reference is passed to use()', () => {
const Context = React.createContext();
function ServerComponent() {
return ReactServer.use(Context);
}
const errors = [];
ReactNoopFlightServer.render(<ServerComponent />, {
onError(x) {
errors.push(x.message);
},
});
expect(errors).toEqual([
'Cannot read a Client Context from a Server Component.',
]);
});
it('should error if a client reference is passed to use()', () => {
const Context = React.createContext();
const ClientContext = clientReference(Context);
function ServerComponent() {
return ReactServer.use(ClientContext);
}
const errors = [];
ReactNoopFlightServer.render(<ServerComponent />, {
onError(x) {
errors.push(x.message);
},
});
expect(errors).toEqual([
'Cannot read a Client Context from a Server Component.',
]);
});
describe('Hooks', () => {
function DivWithId({children}) {
const id = ReactServer.useId();
return <div prop={id}>{children}</div>;
}
it('should support useId', async () => {
function App() {
return (
<>
<DivWithId />
<DivWithId />
</>
);
}
const transport = ReactNoopFlightServer.render(<App />);
await act(async () => {
ReactNoop.render(await ReactNoopFlightClient.read(transport));
});
expect(ReactNoop).toMatchRenderedOutput(
<>
<div prop=":S1:" />
<div prop=":S2:" />
</>,
);
});
it('accepts an identifier prefix that prefixes generated ids', async () => {
function App() {
return (
<>
<DivWithId />
<DivWithId />
</>
);
}
const transport = ReactNoopFlightServer.render(<App />, {
identifierPrefix: 'foo',
});
await act(async () => {
ReactNoop.render(await 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 = ReactServer.useId();
const div = <div prop={id}>{id}</div>;
return <ClientDoublerModuleRef el={div} />;
}
function ClientDoubler({el}) {
Scheduler.log('ClientDoubler');
return (
<>
{el}
{el}
</>
);
}
const ClientDoublerModuleRef = clientReference(ClientDoubler);
const transport = ReactNoopFlightServer.render(<App />);
assertLog([]);
await act(async () => {
ReactNoop.render(await ReactNoopFlightClient.read(transport));
});
assertLog(['ClientDoubler']);
expect(ReactNoop).toMatchRenderedOutput(
<>
<div prop=":S1:">:S1:</div>
<div prop=":S1:">:S1:</div>
</>,
);
});
});
// @gate enableTaint
it('errors when a tainted object is serialized', async () => {
function UserClient({user}) {
return <span>{user.name}</span>;
}
const User = clientReference(UserClient);
const user = {
name: 'Seb',
age: 'rather not say',
};
ReactServer.experimental_taintObjectReference(
"Don't pass the raw user object to the client",
user,
);
const errors = [];
ReactNoopFlightServer.render(<User user={user} />, {
onError(x) {
errors.push(x.message);
},
});
expect(errors).toEqual(["Don't pass the raw user object to the client"]);
});
// @gate enableTaint
it('errors with a specific message when a tainted function is serialized', async () => {
function UserClient({user}) {
return <span>{user.name}</span>;
}
const User = clientReference(UserClient);
function change() {}
ReactServer.experimental_taintObjectReference(
'A change handler cannot be passed to a client component',
change,
);
const errors = [];
ReactNoopFlightServer.render(<User onChange={change} />, {
onError(x) {
errors.push(x.message);
},
});
expect(errors).toEqual([
'A change handler cannot be passed to a client component',
]);
});
// @gate enableTaint
it('errors when a tainted string is serialized', async () => {
function UserClient({user}) {
return <span>{user.name}</span>;
}
const User = clientReference(UserClient);
const process = {
env: {
SECRET: '3e971ecc1485fe78625598bf9b6f85db',
},
};
ReactServer.experimental_taintUniqueValue(
'Cannot pass a secret token to the client',
process,
process.env.SECRET,
);
const errors = [];
ReactNoopFlightServer.render(<User token={process.env.SECRET} />, {
onError(x) {
errors.push(x.message);
},
});
expect(errors).toEqual(['Cannot pass a secret token to the client']);
// This just ensures the process object is kept alive for the life time of
// the test since we're simulating a global as an example.
expect(process.env.SECRET).toBe('3e971ecc1485fe78625598bf9b6f85db');
});
// @gate enableTaint
it('errors when a tainted bigint is serialized', async () => {
function UserClient({user}) {
return <span>{user.name}</span>;
}
const User = clientReference(UserClient);
const currentUser = {
name: 'Seb',
token: BigInt('0x3e971ecc1485fe78625598bf9b6f85dc'),
};
ReactServer.experimental_taintUniqueValue(
'Cannot pass a secret token to the client',
currentUser,
currentUser.token,
);
function App({user}) {
return <User token={user.token} />;
}
const errors = [];
ReactNoopFlightServer.render(<App user={currentUser} />, {
onError(x) {
errors.push(x.message);
},
});
expect(errors).toEqual(['Cannot pass a secret token to the client']);
});
// @gate enableTaint && enableBinaryFlight
it('errors when a tainted binary value is serialized', async () => {
function UserClient({user}) {
return <span>{user.name}</span>;
}
const User = clientReference(UserClient);
const currentUser = {
name: 'Seb',
token: new Uint32Array([0x3e971ecc, 0x1485fe78, 0x625598bf, 0x9b6f85dd]),
};
ReactServer.experimental_taintUniqueValue(
'Cannot pass a secret token to the client',
currentUser,
currentUser.token,
);
function App({user}) {
const clone = user.token.slice();
return <User token={clone} />;
}
const errors = [];
ReactNoopFlightServer.render(<App user={currentUser} />, {
onError(x) {
errors.push(x.message);
},
});
expect(errors).toEqual(['Cannot pass a secret token to the client']);
});
// @gate enableTaint
it('keep a tainted value tainted until the end of any pending requests', async () => {
function UserClient({user}) {
return <span>{user.name}</span>;
}
const User = clientReference(UserClient);
function getUser() {
const user = {
name: 'Seb',
token: '3e971ecc1485fe78625598bf9b6f85db',
};
ReactServer.experimental_taintUniqueValue(
'Cannot pass a secret token to the client',
user,
user.token,
);
return user;
}
function App() {
const user = getUser();
const derivedValue = {...user};
// A garbage collection can happen at any time. Even before the end of
// this request. This would clean up the user object.
gc();
// We should still block the tainted value.
return <User user={derivedValue} />;
}
let errors = [];
ReactNoopFlightServer.render(<App />, {
onError(x) {
errors.push(x.message);
},
});
expect(errors).toEqual(['Cannot pass a secret token to the client']);
// After the previous requests finishes, the token can be rendered again.
errors = [];
ReactNoopFlightServer.render(
<User user={{token: '3e971ecc1485fe78625598bf9b6f85db'}} />,
{
onError(x) {
errors.push(x.message);
},
},
);
expect(errors).toEqual([]);
});
// @gate enableServerComponentKeys
it('preserves state when keying a server component', async () => {
function StatefulClient({name}) {
const [state] = React.useState(name.toLowerCase());
return state;
}
const Stateful = clientReference(StatefulClient);
function Item({item}) {
return (
<div>
{item}
<Stateful name={item} />
</div>
);
}
function Items({items}) {
return items.map(item => {
return <Item key={item} item={item} />;
});
}
const transport = ReactNoopFlightServer.render(
<Items items={['A', 'B', 'C']} />,
);
await act(async () => {
ReactNoop.render(await ReactNoopFlightClient.read(transport));
});
expect(ReactNoop).toMatchRenderedOutput(
<>
<div>Aa</div>
<div>Bb</div>
<div>Cc</div>
</>,
);
const transport2 = ReactNoopFlightServer.render(
<Items items={['B', 'A', 'D', 'C']} />,
);
await act(async () => {
ReactNoop.render(await ReactNoopFlightClient.read(transport2));
});
expect(ReactNoop).toMatchRenderedOutput(
<>
<div>Bb</div>
<div>Aa</div>
<div>Dd</div>
<div>Cc</div>
</>,
);
});
// @gate enableServerComponentKeys
it('does not inherit keys of children inside a server component', async () => {
function StatefulClient({name, initial}) {
const [state] = React.useState(initial);
return state;
}
const Stateful = clientReference(StatefulClient);
function Item({item, initial}) {
// This key is the key of the single item of this component.
// It's NOT part of the key of the list the parent component is
// in.
return (
<div key={item}>
{item}
<Stateful name={item} initial={initial} />
</div>
);
}
function IndirectItem({item, initial}) {
// Even though we render two items with the same child key this key
// should not conflict, because the key belongs to the parent slot.
return <Item key="parent" item={item} initial={initial} />;
}
// These items don't have their own keys because they're in a fixed set
const transport = ReactNoopFlightServer.render(
<>
<Item item="A" initial={1} />
<Item item="B" initial={2} />
<IndirectItem item="C" initial={5} />
<IndirectItem item="C" initial={6} />
</>,
);
await act(async () => {
ReactNoop.render(await ReactNoopFlightClient.read(transport));
});
expect(ReactNoop).toMatchRenderedOutput(
<>
<div>A1</div>
<div>B2</div>
<div>C5</div>
<div>C6</div>
</>,
);
// This means that they shouldn't swap state when the properties update
const transport2 = ReactNoopFlightServer.render(
<>
<Item item="B" initial={3} />
<Item item="A" initial={4} />
<IndirectItem item="C" initial={7} />
<IndirectItem item="C" initial={8} />
</>,
);
await act(async () => {
ReactNoop.render(await ReactNoopFlightClient.read(transport2));
});
expect(ReactNoop).toMatchRenderedOutput(
<>
<div>B3</div>
<div>A4</div>
<div>C5</div>
<div>C6</div>
</>,
);
});
// @gate enableServerComponentKeys
it('shares state between single return and array return in a parent', async () => {
function StatefulClient({name, initial}) {
const [state] = React.useState(initial);
return state;
}
const Stateful = clientReference(StatefulClient);
function Item({item, initial}) {
// This key is the key of the single item of this component.
// It's NOT part of the key of the list the parent component is
// in.
return (
<span key={item}>
{item}
<Stateful name={item} initial={initial} />
</span>
);
}
function Condition({condition}) {
if (condition) {
return <Item item="A" initial={1} />;
}
// The first item in the fragment is the same as the single item.
return (
<>
<Item item="A" initial={2} />
<Item item="B" initial={3} />
</>
);
}
function ConditionPlain({condition}) {
if (condition) {
return (
<span>
C
<Stateful name="C" initial={1} />
</span>
);
}
// The first item in the fragment is the same as the single item.
return (
<>
<span>
C
<Stateful name="C" initial={2} />
</span>
<span>
D
<Stateful name="D" initial={3} />
</span>
</>
);
}
const transport = ReactNoopFlightServer.render(
// This two item wrapper ensures we're already one step inside an array.
// A single item is not the same as a set when it's nested one level.
<>
<div>
<Condition condition={true} />
</div>
<div>
<ConditionPlain condition={true} />
</div>
<div key="keyed">
<ConditionPlain condition={true} />
</div>
</>,
);
await act(async () => {
ReactNoop.render(await ReactNoopFlightClient.read(transport));
});
expect(ReactNoop).toMatchRenderedOutput(
<>
<div>
<span>A1</span>
</div>
<div>
<span>C1</span>
</div>
<div>
<span>C1</span>
</div>
</>,
);
const transport2 = ReactNoopFlightServer.render(
<>
<div>
<Condition condition={false} />
</div>
<div>
<ConditionPlain condition={false} />
</div>
{null}
<div key="keyed">
<ConditionPlain condition={false} />
</div>
</>,
);
await act(async () => {
ReactNoop.render(await ReactNoopFlightClient.read(transport2));
});
// We're intentionally breaking from the semantics here for efficiency of the protocol.
// In the case a Server Component inside a fragment is itself implicitly keyed but its
// return value has a key, then we need a wrapper fragment. This means they can't
// reconcile. To solve this we would need to add a wrapper fragment to every Server
// Component just in case it returns a fragment later which is a lot.
expect(ReactNoop).toMatchRenderedOutput(
<>
<div>
<span>A2{/* This should be A1 ideally */}</span>
<span>B3</span>
</div>
<div>
<span>C1</span>
<span>D3</span>
</div>
<div>
<span>C1</span>
<span>D3</span>
</div>
</>,
);
});
it('shares state between single return and array return in a set', async () => {
function StatefulClient({name, initial}) {
const [state] = React.useState(initial);
return state;
}
const Stateful = clientReference(StatefulClient);
function Item({item, initial}) {
// This key is the key of the single item of this component.
// It's NOT part of the key of the list the parent component is
// in.
return (
<span key={item}>
{item}
<Stateful name={item} initial={initial} />
</span>
);
}
function Condition({condition}) {
if (condition) {
return <Item item="A" initial={1} />;
}
// The first item in the fragment is the same as the single item.
return (
<>
<Item item="A" initial={2} />
<Item item="B" initial={3} />
</>
);
}
function ConditionPlain({condition}) {
if (condition) {
return (
<span>
C
<Stateful name="C" initial={1} />
</span>
);
}
// The first item in the fragment is the same as the single item.
return (
<>
<span>
C
<Stateful name="C" initial={2} />
</span>
<span>
D
<Stateful name="D" initial={3} />
</span>
</>
);
}
const transport = ReactNoopFlightServer.render(
// This two item wrapper ensures we're already one step inside an array.
// A single item is not the same as a set when it's nested one level.
<div>
<Condition condition={true} />
<ConditionPlain condition={true} />
<ConditionPlain key="keyed" condition={true} />
</div>,
);
await act(async () => {
ReactNoop.render(await ReactNoopFlightClient.read(transport));
});
expect(ReactNoop).toMatchRenderedOutput(
<div>
<span>A1</span>
<span>C1</span>
<span>C1</span>
</div>,
);
const transport2 = ReactNoopFlightServer.render(
<div>
<Condition condition={false} />
<ConditionPlain condition={false} />
{null}
<ConditionPlain key="keyed" condition={false} />
</div>,
);
await act(async () => {
ReactNoop.render(await ReactNoopFlightClient.read(transport2));
});
// We're intentionally breaking from the semantics here for efficiency of the protocol.
// The issue with this test scenario is that when the Server Component is in a set,
// the next slot can't be conditionally a fragment or single. That would require wrapping
// in an additional fragment for every single child just in case it every expands to a
// fragment.
expect(ReactNoop).toMatchRenderedOutput(
<div>
<span>A2{/* Should be A1 */}</span>
<span>B3</span>
<span>C2{/* Should be C1 */}</span>
<span>D3</span>
<span>C2{/* Should be C1 */}</span>
<span>D3</span>
</div>,
);
});
// @gate enableServerComponentKeys
it('preserves state with keys split across async work', async () => {
let resolve;
const promise = new Promise(r => (resolve = r));
function StatefulClient({name}) {
const [state] = React.useState(name.toLowerCase());
return state;
}
const Stateful = clientReference(StatefulClient);
function Item({name}) {
if (name === 'A') {
return promise.then(() => (
<div>
{name}
<Stateful name={name} />
</div>
));
}
return (
<div>
{name}
<Stateful name={name} />
</div>
);
}
const transport = ReactNoopFlightServer.render([
<Item key="a" name="A" />,
null,
]);
// Create a gap in the stream
await resolve();
await act(async () => {
ReactNoop.render(await ReactNoopFlightClient.read(transport));
});
expect(ReactNoop).toMatchRenderedOutput(<div>Aa</div>);
const transport2 = ReactNoopFlightServer.render([
null,
<Item key="a" name="B" />,
]);
await act(async () => {
ReactNoop.render(await ReactNoopFlightClient.read(transport2));
});
expect(ReactNoop).toMatchRenderedOutput(<div>Ba</div>);
});
it('preserves debug info for server-to-server pass through', async () => {
function ThirdPartyLazyComponent() {
return <span>!</span>;
}
const lazy = React.lazy(async () => ({
default: <ThirdPartyLazyComponent />,
}));
function ThirdPartyComponent() {
return <span>stranger</span>;
}
function ServerComponent({transport}) {
// This is a Server Component that receives other Server Components from a third party.
const children = ReactNoopFlightClient.read(transport);
return <div>Hello, {children}</div>;
}
const promiseComponent = Promise.resolve(<ThirdPartyComponent />);
const thirdPartyTransport = ReactNoopFlightServer.render(
[promiseComponent, lazy],
{
environmentName: 'third-party',
},
);
// Wait for the lazy component to initialize
await 0;
const transport = ReactNoopFlightServer.render(
<ServerComponent transport={thirdPartyTransport} />,
);
await act(async () => {
const promise = ReactNoopFlightClient.read(transport);
expect(promise._debugInfo).toEqual(
__DEV__ ? [{name: 'ServerComponent', env: 'Server'}] : undefined,
);
const result = await promise;
const thirdPartyChildren = await result.props.children[1];
// We expect the debug info to be transferred from the inner stream to the outer.
expect(thirdPartyChildren[0]._debugInfo).toEqual(
__DEV__
? [{name: 'ThirdPartyComponent', env: 'third-party'}]
: undefined,
);
expect(thirdPartyChildren[1]._debugInfo).toEqual(
__DEV__
? [{name: 'ThirdPartyLazyComponent', env: 'third-party'}]
: undefined,
);
ReactNoop.render(result);
});
expect(ReactNoop).toMatchRenderedOutput(
<div>
Hello, <span>stranger</span>
<span>!</span>
</div>,
);
});
});