@samitouri / QOS-React-1 / commits / 95ec128399

[Flight] Support Keyed Server Components (#28123)

Conceptually a Server Component in the tree is the same as a Client Component. When we render a Server Component with a key, that key should be used as part of the reconciliation process to ensure the children's state are preserved when they move in a set. The key of a child should also be used to clear the state of the children when that key changes. Conversely, if a Server Component doesn't have a key it should get an implicit key based on the slot number. It should not inherit the key of its children since the children don't know if that would collide with other keys in the set the Server Component is rendered in. A Client Component also has an identity based on the function's implementation type. That mainly has to do with the state (or future state after a refactor) that Component might contain. To transfer state between two implementations it needs to be of the same state type. This is not a concern for a Server Components since they never have state so identity doesn't matter. A Component returns a set of children. If it returns a single child, that's the same as returning a fragment of one child. So if you conditionally return a single child or a fragment, they should technically reconcile against each other. The simple way to do this is to simply emit a Fragment for every Server Component. That would be correct in all cases. Unfortunately that is also unfortunate since it bloats the payload in the common cases. It also means that Fiber creates an extra indirection in the runtime. Ideally we want to fold Server Component aways into zero cost on the client. At least where possible. The common cases are that you don't specify a key on a single return child, and that you do specify a key on a Server Component in a dynamic set. The approach in this PR treats a Server Component that returns other Server Components or Lazy Nodes as a sequence that can be folded away. I.e. the parts that don't generate any output in the RSC payload. Instead, it keeps track of their keys on an internal "context". Which gets reset after each new reified JSON node gets rendered. Then we transfer the accumulated keys from any parent Server Components onto the child element. In the simple case, the child just inherits the key of the parent. If the Server Component itself is keyless but a child isn't, we have to add a wrapper fragment to ensure that this fragment gets the implicit key but we can still use the key to reset state. This is unusual though because typically if you keyed something it's because it was already in a fragment. In the case a Server Component is keyed but forks its children using a fragment, we need to key that fragment so that the whole set can move around as one. In theory this could be flattened into a parent array but that gets tricky if something suspends, because then we can't send the siblings early. The main downside of this approach is that switching between single child and fragment in a Server Component isn't always going to reconcile against each other. That's because if we saw a single child first, we'd have to add the fragment preemptively in case it forks later. This semantic of React isn't very well known anyway and it might be ok to break it here for pragmatic reasons. The tests document this discrepancy. Another compromise of this approach is that when combining keys we don't escape them fully. We instead just use a simple `,` separated concat. This is probably good enough in practice. Additionally, since we don't encode the implicit 0 index slot key, you can move things around between parents which shouldn't really reconcile but does. This keeps the keys shorter and more human readable.

Sebastian Markbåge committed Feb 5, 2024 at 09:33 UTC 95ec128399a8b34884cc6bd90a041e03ce5c1844
10 files changed +736 -56
packages/react-client/src/__tests__/ReactFlight-test.js
+425
@@ -1700,4 +1700,429 @@ describe('ReactFlight', () => {
1700
1701 expect(errors).toEqual([]);
1702 });
1703 +
1704 + // @gate enableServerComponentKeys
1705 + it('preserves state when keying a server component', async () => {
1706 + function StatefulClient({name}) {
1707 + const [state] = React.useState(name.toLowerCase());
1708 + return state;
1709 + }
1710 + const Stateful = clientReference(StatefulClient);
1711 +
1712 + function Item({item}) {
1713 + return (
1714 + <div>
1715 + {item}
1716 + <Stateful name={item} />
1717 + </div>
1718 + );
1719 + }
1720 +
1721 + function Items({items}) {
1722 + return items.map(item => {
1723 + return <Item key={item} item={item} />;
1724 + });
1725 + }
1726 +
1727 + const transport = ReactNoopFlightServer.render(
1728 + <Items items={['A', 'B', 'C']} />,
1729 + );
1730 +
1731 + await act(async () => {
1732 + ReactNoop.render(await ReactNoopFlightClient.read(transport));
1733 + });
1734 +
1735 + expect(ReactNoop).toMatchRenderedOutput(
1736 + <>
1737 + <div>Aa</div>
1738 + <div>Bb</div>
1739 + <div>Cc</div>
1740 + </>,
1741 + );
1742 +
1743 + const transport2 = ReactNoopFlightServer.render(
1744 + <Items items={['B', 'A', 'D', 'C']} />,
1745 + );
1746 +
1747 + await act(async () => {
1748 + ReactNoop.render(await ReactNoopFlightClient.read(transport2));
1749 + });
1750 +
1751 + expect(ReactNoop).toMatchRenderedOutput(
1752 + <>
1753 + <div>Bb</div>
1754 + <div>Aa</div>
1755 + <div>Dd</div>
1756 + <div>Cc</div>
1757 + </>,
1758 + );
1759 + });
1760 +
1761 + // @gate enableServerComponentKeys
1762 + it('does not inherit keys of children inside a server component', async () => {
1763 + function StatefulClient({name, initial}) {
1764 + const [state] = React.useState(initial);
1765 + return state;
1766 + }
1767 + const Stateful = clientReference(StatefulClient);
1768 +
1769 + function Item({item, initial}) {
1770 + // This key is the key of the single item of this component.
1771 + // It's NOT part of the key of the list the parent component is
1772 + // in.
1773 + return (
1774 + <div key={item}>
1775 + {item}
1776 + <Stateful name={item} initial={initial} />
1777 + </div>
1778 + );
1779 + }
1780 +
1781 + function IndirectItem({item, initial}) {
1782 + // Even though we render two items with the same child key this key
1783 + // should not conflict, because the key belongs to the parent slot.
1784 + return <Item key="parent" item={item} initial={initial} />;
1785 + }
1786 +
1787 + // These items don't have their own keys because they're in a fixed set
1788 + const transport = ReactNoopFlightServer.render(
1789 + <>
1790 + <Item item="A" initial={1} />
1791 + <Item item="B" initial={2} />
1792 + <IndirectItem item="C" initial={5} />
1793 + <IndirectItem item="C" initial={6} />
1794 + </>,
1795 + );
1796 +
1797 + await act(async () => {
1798 + ReactNoop.render(await ReactNoopFlightClient.read(transport));
1799 + });
1800 +
1801 + expect(ReactNoop).toMatchRenderedOutput(
1802 + <>
1803 + <div>A1</div>
1804 + <div>B2</div>
1805 + <div>C5</div>
1806 + <div>C6</div>
1807 + </>,
1808 + );
1809 +
1810 + // This means that they shouldn't swap state when the properties update
1811 + const transport2 = ReactNoopFlightServer.render(
1812 + <>
1813 + <Item item="B" initial={3} />
1814 + <Item item="A" initial={4} />
1815 + <IndirectItem item="C" initial={7} />
1816 + <IndirectItem item="C" initial={8} />
1817 + </>,
1818 + );
1819 +
1820 + await act(async () => {
1821 + ReactNoop.render(await ReactNoopFlightClient.read(transport2));
1822 + });
1823 +
1824 + expect(ReactNoop).toMatchRenderedOutput(
1825 + <>
1826 + <div>B3</div>
1827 + <div>A4</div>
1828 + <div>C5</div>
1829 + <div>C6</div>
1830 + </>,
1831 + );
1832 + });
1833 +
1834 + // @gate enableServerComponentKeys
1835 + it('shares state between single return and array return in a parent', async () => {
1836 + function StatefulClient({name, initial}) {
1837 + const [state] = React.useState(initial);
1838 + return state;
1839 + }
1840 + const Stateful = clientReference(StatefulClient);
1841 +
1842 + function Item({item, initial}) {
1843 + // This key is the key of the single item of this component.
1844 + // It's NOT part of the key of the list the parent component is
1845 + // in.
1846 + return (
1847 + <span key={item}>
1848 + {item}
1849 + <Stateful name={item} initial={initial} />
1850 + </span>
1851 + );
1852 + }
1853 +
1854 + function Condition({condition}) {
1855 + if (condition) {
1856 + return <Item item="A" initial={1} />;
1857 + }
1858 + // The first item in the fragment is the same as the single item.
1859 + return (
1860 + <>
1861 + <Item item="A" initial={2} />
1862 + <Item item="B" initial={3} />
1863 + </>
1864 + );
1865 + }
1866 +
1867 + function ConditionPlain({condition}) {
1868 + if (condition) {
1869 + return (
1870 + <span>
1871 + C
1872 + <Stateful name="C" initial={1} />
1873 + </span>
1874 + );
1875 + }
1876 + // The first item in the fragment is the same as the single item.
1877 + return (
1878 + <>
1879 + <span>
1880 + C
1881 + <Stateful name="C" initial={2} />
1882 + </span>
1883 + <span>
1884 + D
1885 + <Stateful name="D" initial={3} />
1886 + </span>
1887 + </>
1888 + );
1889 + }
1890 +
1891 + const transport = ReactNoopFlightServer.render(
1892 + // This two item wrapper ensures we're already one step inside an array.
1893 + // A single item is not the same as a set when it's nested one level.
1894 + <>
1895 + <div>
1896 + <Condition condition={true} />
1897 + </div>
1898 + <div>
1899 + <ConditionPlain condition={true} />
1900 + </div>
1901 + <div key="keyed">
1902 + <ConditionPlain condition={true} />
1903 + </div>
1904 + </>,
1905 + );
1906 +
1907 + await act(async () => {
1908 + ReactNoop.render(await ReactNoopFlightClient.read(transport));
1909 + });
1910 +
1911 + expect(ReactNoop).toMatchRenderedOutput(
1912 + <>
1913 + <div>
1914 + <span>A1</span>
1915 + </div>
1916 + <div>
1917 + <span>C1</span>
1918 + </div>
1919 + <div>
1920 + <span>C1</span>
1921 + </div>
1922 + </>,
1923 + );
1924 +
1925 + const transport2 = ReactNoopFlightServer.render(
1926 + <>
1927 + <div>
1928 + <Condition condition={false} />
1929 + </div>
1930 + <div>
1931 + <ConditionPlain condition={false} />
1932 + </div>
1933 + {null}
1934 + <div key="keyed">
1935 + <ConditionPlain condition={false} />
1936 + </div>
1937 + </>,
1938 + );
1939 +
1940 + await act(async () => {
1941 + ReactNoop.render(await ReactNoopFlightClient.read(transport2));
1942 + });
1943 +
1944 + // We're intentionally breaking from the semantics here for efficiency of the protocol.
1945 + // In the case a Server Component inside a fragment is itself implicitly keyed but its
1946 + // return value has a key, then we need a wrapper fragment. This means they can't
1947 + // reconcile. To solve this we would need to add a wrapper fragment to every Server
1948 + // Component just in case it returns a fragment later which is a lot.
1949 + expect(ReactNoop).toMatchRenderedOutput(
1950 + <>
1951 + <div>
1952 + <span>A2{/* This should be A1 ideally */}</span>
1953 + <span>B3</span>
1954 + </div>
1955 + <div>
1956 + <span>C1</span>
1957 + <span>D3</span>
1958 + </div>
1959 + <div>
1960 + <span>C1</span>
1961 + <span>D3</span>
1962 + </div>
1963 + </>,
1964 + );
1965 + });
1966 +
1967 + it('shares state between single return and array return in a set', async () => {
1968 + function StatefulClient({name, initial}) {
1969 + const [state] = React.useState(initial);
1970 + return state;
1971 + }
1972 + const Stateful = clientReference(StatefulClient);
1973 +
1974 + function Item({item, initial}) {
1975 + // This key is the key of the single item of this component.
1976 + // It's NOT part of the key of the list the parent component is
1977 + // in.
1978 + return (
1979 + <span key={item}>
1980 + {item}
1981 + <Stateful name={item} initial={initial} />
1982 + </span>
1983 + );
1984 + }
1985 +
1986 + function Condition({condition}) {
1987 + if (condition) {
1988 + return <Item item="A" initial={1} />;
1989 + }
1990 + // The first item in the fragment is the same as the single item.
1991 + return (
1992 + <>
1993 + <Item item="A" initial={2} />
1994 + <Item item="B" initial={3} />
1995 + </>
1996 + );
1997 + }
1998 +
1999 + function ConditionPlain({condition}) {
2000 + if (condition) {
2001 + return (
2002 + <span>
2003 + C
2004 + <Stateful name="C" initial={1} />
2005 + </span>
2006 + );
2007 + }
2008 + // The first item in the fragment is the same as the single item.
2009 + return (
2010 + <>
2011 + <span>
2012 + C
2013 + <Stateful name="C" initial={2} />
2014 + </span>
2015 + <span>
2016 + D
2017 + <Stateful name="D" initial={3} />
2018 + </span>
2019 + </>
2020 + );
2021 + }
2022 +
2023 + const transport = ReactNoopFlightServer.render(
2024 + // This two item wrapper ensures we're already one step inside an array.
2025 + // A single item is not the same as a set when it's nested one level.
2026 + <div>
2027 + <Condition condition={true} />
2028 + <ConditionPlain condition={true} />
2029 + <ConditionPlain key="keyed" condition={true} />
2030 + </div>,
2031 + );
2032 +
2033 + await act(async () => {
2034 + ReactNoop.render(await ReactNoopFlightClient.read(transport));
2035 + });
2036 +
2037 + expect(ReactNoop).toMatchRenderedOutput(
2038 + <div>
2039 + <span>A1</span>
2040 + <span>C1</span>
2041 + <span>C1</span>
2042 + </div>,
2043 + );
2044 +
2045 + const transport2 = ReactNoopFlightServer.render(
2046 + <div>
2047 + <Condition condition={false} />
2048 + <ConditionPlain condition={false} />
2049 + {null}
2050 + <ConditionPlain key="keyed" condition={false} />
2051 + </div>,
2052 + );
2053 +
2054 + await act(async () => {
2055 + ReactNoop.render(await ReactNoopFlightClient.read(transport2));
2056 + });
2057 +
2058 + // We're intentionally breaking from the semantics here for efficiency of the protocol.
2059 + // The issue with this test scenario is that when the Server Component is in a set,
2060 + // the next slot can't be conditionally a fragment or single. That would require wrapping
2061 + // in an additional fragment for every single child just in case it every expands to a
2062 + // fragment.
2063 + expect(ReactNoop).toMatchRenderedOutput(
2064 + <div>
2065 + <span>A2{/* Should be A1 */}</span>
2066 + <span>B3</span>
2067 + <span>C2{/* Should be C1 */}</span>
2068 + <span>D3</span>
2069 + <span>C2{/* Should be C1 */}</span>
2070 + <span>D3</span>
2071 + </div>,
2072 + );
2073 + });
2074 +
2075 + // @gate enableServerComponentKeys
2076 + it('preserves state with keys split across async work', async () => {
2077 + let resolve;
2078 + const promise = new Promise(r => (resolve = r));
2079 +
2080 + function StatefulClient({name}) {
2081 + const [state] = React.useState(name.toLowerCase());
2082 + return state;
2083 + }
2084 + const Stateful = clientReference(StatefulClient);
2085 +
2086 + function Item({name}) {
2087 + if (name === 'A') {
2088 + return promise.then(() => (
2089 + <div>
2090 + {name}
2091 + <Stateful name={name} />
2092 + </div>
2093 + ));
2094 + }
2095 + return (
2096 + <div>
2097 + {name}
2098 + <Stateful name={name} />
2099 + </div>
2100 + );
2101 + }
2102 +
2103 + const transport = ReactNoopFlightServer.render([
2104 + <Item key="a" name="A" />,
2105 + null,
2106 + ]);
2107 +
2108 + // Create a gap in the stream
2109 + await resolve();
2110 +
2111 + await act(async () => {
2112 + ReactNoop.render(await ReactNoopFlightClient.read(transport));
2113 + });
2114 +
2115 + expect(ReactNoop).toMatchRenderedOutput(<div>Aa</div>);
2116 +
2117 + const transport2 = ReactNoopFlightServer.render([
2118 + null,
2119 + <Item key="a" name="B" />,
2120 + ]);
2121 +
2122 + await act(async () => {
2123 + ReactNoop.render(await ReactNoopFlightClient.read(transport2));
2124 + });
2125 +
2126 + expect(ReactNoop).toMatchRenderedOutput(<div>Ba</div>);
2127 + });
2128 });
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js
+43 -8
@@ -226,20 +226,55 @@ describe('ReactFlightDOMEdge', () => {
226 const [stream1, stream2] = passThrough(stream).tee();
227
228 const serializedContent = await readResult(stream1);
229 +
230 expect(serializedContent.length).toBeLessThan(400);
231 expect(timesRendered).toBeLessThan(5);
232
232 - const result = await ReactServerDOMClient.createFromReadableStream(
233 - stream2,
234 - {
235 - ssrManifest: {
236 - moduleMap: null,
237 - moduleLoading: null,
238 - },
233 + const model = await ReactServerDOMClient.createFromReadableStream(stream2, {
234 + ssrManifest: {
235 + moduleMap: null,
236 + moduleLoading: null,
237 },
238 + });
239 +
240 + // Use the SSR render to resolve any lazy elements
241 + const ssrStream = await ReactDOMServer.renderToReadableStream(model);
242 + // Should still match the result when parsed
243 + const result = await readResult(ssrStream);
244 + expect(result).toEqual(resolvedChildren.join('<!-- -->'));
245 + });
246 +
247 + it('should execute repeated host components only once', async () => {
248 + const div = <div>this is a long return value</div>;
249 + let timesRendered = 0;
250 + function ServerComponent() {
251 + timesRendered++;
252 + return div;
253 + }
254 + const element = <ServerComponent />;
255 + const children = new Array(30).fill(element);
256 + const resolvedChildren = new Array(30).fill(
257 + '<div>this is a long return value</div>',
258 );
259 + const stream = ReactServerDOMServer.renderToReadableStream(children);
260 + const [stream1, stream2] = passThrough(stream).tee();
261 +
262 + const serializedContent = await readResult(stream1);
263 + expect(serializedContent.length).toBeLessThan(400);
264 + expect(timesRendered).toBeLessThan(5);
265 +
266 + const model = await ReactServerDOMClient.createFromReadableStream(stream2, {
267 + ssrManifest: {
268 + moduleMap: null,
269 + moduleLoading: null,
270 + },
271 + });
272 +
273 + // Use the SSR render to resolve any lazy elements
274 + const ssrStream = await ReactDOMServer.renderToReadableStream(model);
275 // Should still match the result when parsed
242 - expect(result).toEqual(resolvedChildren);
276 + const result = await readResult(ssrStream);
277 + expect(result).toEqual(resolvedChildren.join(''));
278 });
279
280 it('should execute repeated server components in a compact form', async () => {
packages/react-server/src/ReactFlightServer.js
+254 -48
@@ -16,6 +16,7 @@ import {
16 enablePostpone,
17 enableTaint,
18 enableServerContext,
19 + enableServerComponentKeys,
20 } from 'shared/ReactFeatureFlags';
21
22 import {
@@ -181,6 +182,8 @@ type Task = {
182 model: ReactClientValue,
183 ping: () => void,
184 toJSON: (key: string, value: ReactClientValue) => ReactJSONValue,
185 + keyPath: null | string, // parent server component keys
186 + implicitSlot: boolean, // true if the root server component of this sequence had a null key
187 context: ContextSnapshot,
188 thenableState: ThenableState | null,
189 };
@@ -314,7 +317,14 @@ export function createRequest(
317 };
318 request.pendingChunks++;
319 const rootContext = createRootContext(context);
317 - const rootTask = createTask(request, model, rootContext, abortSet);
320 + const rootTask = createTask(
321 + request,
322 + model,
323 + null,
324 + false,
325 + rootContext,
326 + abortSet,
327 + );
328 pingedTasks.push(rootTask);
329 return request;
330 }
@@ -338,12 +348,18 @@ function createRootContext(
348
349 const POP = {};
350
341 -function serializeThenable(request: Request, thenable: Thenable<any>): number {
351 +function serializeThenable(
352 + request: Request,
353 + task: Task,
354 + thenable: Thenable<any>,
355 +): number {
356 request.pendingChunks++;
357 const newTask = createTask(
358 request,
359 null,
346 - getActiveContext(),
360 + task.keyPath, // the server component sequence continues through Promise-as-a-child.
361 + task.implicitSlot,
362 + task.context,
363 request.abortableTasks,
364 );
365
@@ -500,11 +516,86 @@ function createLazyWrapperAroundWakeable(wakeable: Wakeable) {
516 return lazyType;
517 }
518
519 +function renderFragment(
520 + request: Request,
521 + task: Task,
522 + children: $ReadOnlyArray<ReactClientValue>,
523 +): ReactJSONValue {
524 + if (!enableServerComponentKeys) {
525 + return children;
526 + }
527 + if (task.keyPath !== null) {
528 + // We have a Server Component that specifies a key but we're now splitting
529 + // the tree using a fragment.
530 + const fragment = [
531 + REACT_ELEMENT_TYPE,
532 + REACT_FRAGMENT_TYPE,
533 + task.keyPath,
534 + {children},
535 + ];
536 + if (!task.implicitSlot) {
537 + // If this was keyed inside a set. I.e. the outer Server Component was keyed
538 + // then we need to handle reorders of the whole set. To do this we need to wrap
539 + // this array in a keyed Fragment.
540 + return fragment;
541 + }
542 + // If the outer Server Component was implicit but then an inner one had a key
543 + // we don't actually need to be able to move the whole set around. It'll always be
544 + // in an implicit slot. The key only exists to be able to reset the state of the
545 + // children. We could achieve the same effect by passing on the keyPath to the next
546 + // set of components inside the fragment. This would also allow a keyless fragment
547 + // reconcile against a single child.
548 + // Unfortunately because of JSON.stringify, we can't call the recursive loop for
549 + // each child within this context because we can't return a set with already resolved
550 + // values. E.g. a string would get double encoded. Returning would pop the context.
551 + // So instead, we wrap it with an unkeyed fragment and inner keyed fragment.
552 + return [fragment];
553 + }
554 + // Since we're yielding here, that implicitly resets the keyPath context on the
555 + // way up. Which is what we want since we've consumed it. If this changes to
556 + // be recursive serialization, we need to reset the keyPath and implicitSlot,
557 + // before recursing here.
558 + return children;
559 +}
560 +
561 +function renderClientElement(
562 + task: Task,
563 + type: any,
564 + key: null | string,
565 + props: any,
566 +): ReactJSONValue {
567 + if (!enableServerComponentKeys) {
568 + return [REACT_ELEMENT_TYPE, type, key, props];
569 + }
570 + // We prepend the terminal client element that actually gets serialized with
571 + // the keys of any Server Components which are not serialized.
572 + const keyPath = task.keyPath;
573 + if (key === null) {
574 + key = keyPath;
575 + } else if (keyPath !== null) {
576 + key = keyPath + ',' + key;
577 + }
578 + const element = [REACT_ELEMENT_TYPE, type, key, props];
579 + if (task.implicitSlot && key !== null) {
580 + // The root Server Component had no key so it was in an implicit slot.
581 + // If we had a key lower, it would end up in that slot with an explicit key.
582 + // We wrap the element in a fragment to give it an implicit key slot with
583 + // an inner explicit key.
584 + return [element];
585 + }
586 + // Since we're yielding here, that implicitly resets the keyPath context on the
587 + // way up. Which is what we want since we've consumed it. If this changes to
588 + // be recursive serialization, we need to reset the keyPath and implicitSlot,
589 + // before recursing here. We also need to reset it once we render into an array
590 + // or anything else too which we also get implicitly.
591 + return element;
592 +}
593 +
594 function renderElement(
595 request: Request,
596 task: Task,
597 type: any,
507 - key: null | React$Key,
598 + key: null | string,
599 ref: mixed,
600 props: any,
601 ): ReactJSONValue {
@@ -525,7 +616,7 @@ function renderElement(
616 if (typeof type === 'function') {
617 if (isClientReference(type)) {
618 // This is a reference to a Client Component.
528 - return [REACT_ELEMENT_TYPE, type, key, props];
619 + return renderClientElement(task, type, key, props);
620 }
621 // This is a server-side component.
622
@@ -552,31 +643,52 @@ function renderElement(
643 // the thenable here.
644 result = createLazyWrapperAroundWakeable(result);
645 }
555 - return renderModelDestructive(request, task, emptyRoot, '', result);
646 + // Track this element's key on the Server Component on the keyPath context..
647 + const prevKeyPath = task.keyPath;
648 + const prevImplicitSlot = task.implicitSlot;
649 + if (key !== null) {
650 + // Append the key to the path. Technically a null key should really add the child
651 + // index. We don't do that to hold the payload small and implementation simple.
652 + task.keyPath = prevKeyPath === null ? key : prevKeyPath + ',' + key;
653 + } else if (prevKeyPath === null) {
654 + // This sequence of Server Components has no keys. This means that it was rendered
655 + // in a slot that needs to assign an implicit key. Even if children below have
656 + // explicit keys, they should not be used for the outer most key since it might
657 + // collide with other slots in that set.
658 + task.implicitSlot = true;
659 + }
660 + const json = renderModelDestructive(request, task, emptyRoot, '', result);
661 + task.keyPath = prevKeyPath;
662 + task.implicitSlot = prevImplicitSlot;
663 + return json;
664 } else if (typeof type === 'string') {
665 // This is a host element. E.g. HTML.
558 - return [REACT_ELEMENT_TYPE, type, key, props];
666 + return renderClientElement(task, type, key, props);
667 } else if (typeof type === 'symbol') {
560 - if (type === REACT_FRAGMENT_TYPE) {
668 + if (type === REACT_FRAGMENT_TYPE && key === null) {
669 // For key-less fragments, we add a small optimization to avoid serializing
670 // it as a wrapper.
563 - // TODO: If a key is specified, we should propagate its key to any children.
564 - // Same as if a Server Component has a key.
565 - return renderModelDestructive(
671 + const prevImplicitSlot = task.implicitSlot;
672 + if (task.keyPath === null) {
673 + task.implicitSlot = true;
674 + }
675 + const json = renderModelDestructive(
676 request,
677 task,
678 emptyRoot,
679 '',
680 props.children,
681 );
682 + task.implicitSlot = prevImplicitSlot;
683 + return json;
684 }
685 // This might be a built-in React component. We'll let the client decide.
686 // Any built-in works as long as its props are serializable.
575 - return [REACT_ELEMENT_TYPE, type, key, props];
687 + return renderClientElement(task, type, key, props);
688 } else if (type != null && typeof type === 'object') {
689 if (isClientReference(type)) {
690 // This is a reference to a Client Component.
579 - return [REACT_ELEMENT_TYPE, type, key, props];
691 + return renderClientElement(task, type, key, props);
692 }
693 switch (type.$$typeof) {
694 case REACT_LAZY_TYPE: {
@@ -596,7 +708,29 @@ function renderElement(
708
709 prepareToUseHooksForComponent(prevThenableState);
710 const result = render(props, undefined);
599 - return renderModelDestructive(request, task, emptyRoot, '', result);
711 + const prevKeyPath = task.keyPath;
712 + const prevImplicitSlot = task.implicitSlot;
713 + if (key !== null) {
714 + // Append the key to the path. Technically a null key should really add the child
715 + // index. We don't do that to hold the payload small and implementation simple.
716 + task.keyPath = prevKeyPath === null ? key : prevKeyPath + ',' + key;
717 + } else if (prevKeyPath === null) {
718 + // This sequence of Server Components has no keys. This means that it was rendered
719 + // in a slot that needs to assign an implicit key. Even if children below have
720 + // explicit keys, they should not be used for the outer most key since it might
721 + // collide with other slots in that set.
722 + task.implicitSlot = true;
723 + }
724 + const json = renderModelDestructive(
725 + request,
726 + task,
727 + emptyRoot,
728 + '',
729 + result,
730 + );
731 + task.keyPath = prevKeyPath;
732 + task.implicitSlot = prevImplicitSlot;
733 + return json;
734 }
735 case REACT_MEMO_TYPE: {
736 return renderElement(request, task, type.type, key, ref, props);
@@ -618,13 +752,13 @@ function renderElement(
752 );
753 }
754 }
621 - return [
622 - REACT_ELEMENT_TYPE,
755 + return renderClientElement(
756 + task,
757 type,
758 key,
759 // Rely on __popProvider being serialized last to pop the provider.
760 {value: props.value, children: props.children, __pop: POP},
627 - ];
761 + );
762 }
763 // Fallthrough
764 }
@@ -647,18 +781,31 @@ function pingTask(request: Request, task: Task): void {
781 function createTask(
782 request: Request,
783 model: ReactClientValue,
784 + keyPath: null | string,
785 + implicitSlot: boolean,
786 context: ContextSnapshot,
787 abortSet: Set<Task>,
788 ): Task {
789 const id = request.nextChunkId++;
790 if (typeof model === 'object' && model !== null) {
655 - // Register this model as having the ID we're about to write.
656 - request.writtenObjects.set(model, id);
791 + // If we're about to write this into a new task we can assign it an ID early so that
792 + // any other references can refer to the value we're about to write.
793 + if (
794 + enableServerComponentKeys &&
795 + (keyPath !== null || implicitSlot || context !== rootContextSnapshot)
796 + ) {
797 + // If we're in some kind of context we can't necessarily reuse this object depending
798 + // what parent components are used.
799 + } else {
800 + request.writtenObjects.set(model, id);
801 + }
802 }
803 const task: Task = {
804 id,
805 status: PENDING,
806 model,
807 + keyPath,
808 + implicitSlot,
809 context,
810 ping: () => pingTask(request, task),
811 toJSON: function (
@@ -855,7 +1002,9 @@ function outlineModel(request: Request, value: ReactClientValue): number {
1002 const newTask = createTask(
1003 request,
1004 value,
858 - getActiveContext(),
1005 + null, // The way we use outlining is for reusing an object.
1006 + false, // It makes no sense for that use case to be contextual.
1007 + rootContextSnapshot, // Therefore we don't pass any contextual information along.
1008 request.abortableTasks,
1009 );
1010 retryTask(request, newTask);
@@ -988,6 +1137,8 @@ function renderModel(
1137 key: string,
1138 value: ReactClientValue,
1139 ): ReactJSONValue {
1140 + const prevKeyPath = task.keyPath;
1141 + const prevImplicitSlot = task.implicitSlot;
1142 try {
1143 return renderModelDestructive(request, task, parent, key, value);
1144 } catch (thrownValue) {
@@ -1016,12 +1167,20 @@ function renderModel(
1167 const newTask = createTask(
1168 request,
1169 task.model,
1019 - getActiveContext(),
1170 + task.keyPath,
1171 + task.implicitSlot,
1172 + task.context,
1173 request.abortableTasks,
1174 );
1175 const ping = newTask.ping;
1176 (x: any).then(ping, ping);
1177 newTask.thenableState = getThenableStateAfterSuspending();
1178 +
1179 + // Restore the context. We assume that this will be restored by the inner
1180 + // functions in case nothing throws so we don't use "finally" here.
1181 + task.keyPath = prevKeyPath;
1182 + task.implicitSlot = prevImplicitSlot;
1183 +
1184 if (wasReactNode) {
1185 return serializeLazyID(newTask.id);
1186 }
@@ -1034,12 +1193,24 @@ function renderModel(
1193 const postponeId = request.nextChunkId++;
1194 logPostpone(request, postponeInstance.message);
1195 emitPostponeChunk(request, postponeId, postponeInstance);
1196 +
1197 + // Restore the context. We assume that this will be restored by the inner
1198 + // functions in case nothing throws so we don't use "finally" here.
1199 + task.keyPath = prevKeyPath;
1200 + task.implicitSlot = prevImplicitSlot;
1201 +
1202 if (wasReactNode) {
1203 return serializeLazyID(postponeId);
1204 }
1205 return serializeByValueID(postponeId);
1206 }
1207 }
1208 +
1209 + // Restore the context. We assume that this will be restored by the inner
1210 + // functions in case nothing throws so we don't use "finally" here.
1211 + task.keyPath = prevKeyPath;
1212 + task.implicitSlot = prevImplicitSlot;
1213 +
1214 if (wasReactNode) {
1215 // Something errored. We'll still send everything we have up until this point.
1216 // We'll replace this element with a lazy reference that throws on the client
@@ -1089,18 +1260,31 @@ function renderModelDestructive(
1260 const writtenObjects = request.writtenObjects;
1261 const existingId = writtenObjects.get(value);
1262 if (existingId !== undefined) {
1092 - if (existingId === -1) {
1093 - // Seen but not yet outlined.
1094 - const newId = outlineModel(request, value);
1095 - return serializeByValueID(newId);
1263 + if (
1264 + enableServerComponentKeys &&
1265 + (task.keyPath !== null ||
1266 + task.implicitSlot ||
1267 + task.context !== rootContextSnapshot)
1268 + ) {
1269 + // If we're in some kind of context we can't reuse the result of this render or
1270 + // previous renders of this element. We only reuse elements if they're not wrapped
1271 + // by another Server Component.
1272 } else if (modelRoot === value) {
1273 // This is the ID we're currently emitting so we need to write it
1274 // once but if we discover it again, we refer to it by id.
1275 modelRoot = null;
1276 + } else if (existingId === -1) {
1277 + // Seen but not yet outlined.
1278 + // TODO: If we throw here we can treat this as suspending which causes an outline
1279 + // but that is able to reuse the same task if we're already in one but then that
1280 + // will be a lazy future value rather than guaranteed to exist but maybe that's good.
1281 + const newId = outlineModel(request, (value: any));
1282 + return serializeLazyID(newId);
1283 } else {
1101 - // We've already emitted this as an outlined object, so we can
1102 - // just refer to that by its existing ID.
1103 - return serializeByValueID(existingId);
1284 + // We've already emitted this as an outlined object, so we can refer to that by its
1285 + // existing ID. We use a lazy reference since, unlike plain objects, elements might
1286 + // suspend so it might not have emitted yet even if we have the ID for it.
1287 + return serializeLazyID(existingId);
1288 }
1289 } else {
1290 // This is the first time we've seen this object. We may never see it again
@@ -1108,13 +1292,13 @@ function renderModelDestructive(
1292 writtenObjects.set(value, -1);
1293 }
1294
1111 - // TODO: Concatenate keys of parents onto children.
1295 const element: React$Element<any> = (value: any);
1296 // Attempt to render the Server Component.
1297 return renderElement(
1298 request,
1299 task,
1300 element.type,
1301 + // $FlowFixMe[incompatible-call] the key of an element is null | string
1302 element.key,
1303 element.ref,
1304 element.props,
@@ -1155,7 +1339,18 @@ function renderModelDestructive(
1339 // $FlowFixMe[method-unbinding]
1340 if (typeof value.then === 'function') {
1341 if (existingId !== undefined) {
1158 - if (modelRoot === value) {
1342 + if (
1343 + enableServerComponentKeys &&
1344 + (task.keyPath !== null ||
1345 + task.implicitSlot ||
1346 + task.context !== rootContextSnapshot)
1347 + ) {
1348 + // If we're in some kind of context we can't reuse the result of this render or
1349 + // previous renders of this element. We only reuse Promises if they're not wrapped
1350 + // by another Server Component.
1351 + const promiseId = serializeThenable(request, task, (value: any));
1352 + return serializePromiseID(promiseId);
1353 + } else if (modelRoot === value) {
1354 // This is the ID we're currently emitting so we need to write it
1355 // once but if we discover it again, we refer to it by id.
1356 modelRoot = null;
@@ -1166,7 +1361,7 @@ function renderModelDestructive(
1361 }
1362 // We assume that any object with a .then property is a "Thenable" type,
1363 // or a Promise type. Either of which can be represented by a Promise.
1169 - const promiseId = serializeThenable(request, (value: any));
1364 + const promiseId = serializeThenable(request, task, (value: any));
1365 writtenObjects.set(value, promiseId);
1366 return serializePromiseID(promiseId);
1367 }
@@ -1195,14 +1390,14 @@ function renderModelDestructive(
1390 }
1391
1392 if (existingId !== undefined) {
1198 - if (existingId === -1) {
1199 - // Seen but not yet outlined.
1200 - const newId = outlineModel(request, value);
1201 - return serializeByValueID(newId);
1202 - } else if (modelRoot === value) {
1393 + if (modelRoot === value) {
1394 // This is the ID we're currently emitting so we need to write it
1395 // once but if we discover it again, we refer to it by id.
1396 modelRoot = null;
1397 + } else if (existingId === -1) {
1398 + // Seen but not yet outlined.
1399 + const newId = outlineModel(request, (value: any));
1400 + return serializeByValueID(newId);
1401 } else {
1402 // We've already emitted this as an outlined object, so we can
1403 // just refer to that by its existing ID.
@@ -1215,8 +1410,7 @@ function renderModelDestructive(
1410 }
1411
1412 if (isArray(value)) {
1218 - // $FlowFixMe[incompatible-return]
1219 - return value;
1413 + return renderFragment(request, task, value);
1414 }
1415
1416 if (value instanceof Map) {
@@ -1282,7 +1476,7 @@ function renderModelDestructive(
1476
1477 const iteratorFn = getIteratorFn(value);
1478 if (iteratorFn) {
1285 - return Array.from((value: any));
1479 + return renderFragment(request, task, Array.from((value: any)));
1480 }
1481
1482 // Verify that this is a simple plain object.
@@ -1582,6 +1776,7 @@ function retryTask(request: Request, task: Task): void {
1776 return;
1777 }
1778
1779 + const prevContext = getActiveContext();
1780 switchContext(task.context);
1781 try {
1782 // Track the root so we know that we have to emit this object even though it
@@ -1602,15 +1797,22 @@ function retryTask(request: Request, task: Task): void {
1797 // Track the root again for the resolved object.
1798 modelRoot = resolvedModel;
1799
1605 - // If the value is a string, it means it's a terminal value adn we already escaped it
1606 - // We don't need to escape it again so it's not passed the toJSON replacer.
1607 - // Object might contain unresolved values like additional elements.
1608 - // This is simulating what the JSON loop would do if this was part of it.
1609 - // $FlowFixMe[incompatible-type] stringify can return null
1610 - const json: string =
1611 - typeof resolvedModel === 'string'
1612 - ? stringify(resolvedModel)
1613 - : stringify(resolvedModel, task.toJSON);
1800 + // The keyPath resets at any terminal child node.
1801 + task.keyPath = null;
1802 + task.implicitSlot = false;
1803 +
1804 + let json: string;
1805 + if (typeof resolvedModel === 'object' && resolvedModel !== null) {
1806 + // Object might contain unresolved values like additional elements.
1807 + // This is simulating what the JSON loop would do if this was part of it.
1808 + // $FlowFixMe[incompatible-type] stringify can return null for undefined but we never do
1809 + json = stringify(resolvedModel, task.toJSON);
1810 + } else {
1811 + // If the value is a string, it means it's a terminal value and we already escaped it
1812 + // We don't need to escape it again so it's not passed the toJSON replacer.
1813 + // $FlowFixMe[incompatible-type] stringify can return null for undefined but we never do
1814 + json = stringify(resolvedModel);
1815 + }
1816 emitModelChunk(request, task.id, json);
1817
1818 request.abortableTasks.delete(task);
@@ -1646,6 +1848,10 @@ function retryTask(request: Request, task: Task): void {
1848 task.status = ERRORED;
1849 const digest = logRecoverableError(request, x);
1850 emitErrorChunk(request, task.id, digest, x);
1851 + } finally {
1852 + if (enableServerContext) {
1853 + switchContext(prevContext);
1854 + }
1855 }
1856 }
1857
packages/shared/ReactFeatureFlags.js
+2
@@ -15,6 +15,8 @@
15
16 export const enableComponentStackLocations = true;
17
18 +export const enableServerComponentKeys = __EXPERIMENTAL__;
19 +
20 // -----------------------------------------------------------------------------
21 // Killswitch
22 //
packages/shared/forks/ReactFeatureFlags.native-fb.js
+2
@@ -93,5 +93,7 @@ export const enableFizzExternalRuntime = false;
93 export const enableAsyncActions = false;
94 export const enableUseDeferredValueInitialArg = true;
95
96 +export const enableServerComponentKeys = true;
97 +
98 // Flow magic to verify the exports of this file match the original version.
99 ((((null: any): ExportsType): FeatureFlagsType): ExportsType);
packages/shared/forks/ReactFeatureFlags.native-oss.js
+2
@@ -85,5 +85,7 @@ export const useMicrotasksForSchedulingInFabric = false;
85 export const passChildrenWhenCloningPersistedNodes = false;
86 export const enableUseDeferredValueInitialArg = __EXPERIMENTAL__;
87
88 +export const enableServerComponentKeys = true;
89 +
90 // Flow magic to verify the exports of this file match the original version.
91 ((((null: any): ExportsType): FeatureFlagsType): ExportsType);
packages/shared/forks/ReactFeatureFlags.test-renderer.js
+2
@@ -85,5 +85,7 @@ export const useMicrotasksForSchedulingInFabric = false;
85 export const passChildrenWhenCloningPersistedNodes = false;
86 export const enableUseDeferredValueInitialArg = __EXPERIMENTAL__;
87
88 +export const enableServerComponentKeys = true;
89 +
90 // Flow magic to verify the exports of this file match the original version.
91 ((((null: any): ExportsType): FeatureFlagsType): ExportsType);
packages/shared/forks/ReactFeatureFlags.test-renderer.native.js
+2
@@ -82,5 +82,7 @@ export const useMicrotasksForSchedulingInFabric = false;
82 export const passChildrenWhenCloningPersistedNodes = false;
83 export const enableUseDeferredValueInitialArg = __EXPERIMENTAL__;
84
85 +export const enableServerComponentKeys = true;
86 +
87 // Flow magic to verify the exports of this file match the original version.
88 ((((null: any): ExportsType): FeatureFlagsType): ExportsType);
packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
+2
@@ -85,5 +85,7 @@ export const useMicrotasksForSchedulingInFabric = false;
85 export const passChildrenWhenCloningPersistedNodes = false;
86 export const enableUseDeferredValueInitialArg = true;
87
88 +export const enableServerComponentKeys = true;
89 +
90 // Flow magic to verify the exports of this file match the original version.
91 ((((null: any): ExportsType): FeatureFlagsType): ExportsType);
packages/shared/forks/ReactFeatureFlags.www.js
+2
@@ -112,5 +112,7 @@ export const passChildrenWhenCloningPersistedNodes = false;
112
113 export const enableAsyncDebugInfo = false;
114
115 +export const enableServerComponentKeys = true;
116 +
117 // Flow magic to verify the exports of this file match the original version.
118 ((((null: any): ExportsType): FeatureFlagsType): ExportsType);