From 408d055a3b89794088130ed39bf42ca540766275 Mon Sep 17 00:00:00 2001 From: Jack Pope Date: Wed, 30 Apr 2025 10:47:18 -0400 Subject: [PATCH 1/4] Add Fragment Refs to Fabric with intersection observer support (#33056) Adds Fragment Ref support to RN through the Fabric config, starting with `observeUsing`/`unobserveUsing`. This is mostly a copy from the implementation on DOM, and some of it can likely be shared in the future but keeping it separate for now and we can refactor as we add more features. Added a basic test with Fabric, but testing specific methods requires so much mocking that it doesn't seem valuable here. I built Fabric and ran on the Catalyst app internally to test with intersection observers end to end. --- .../src/ReactFiberConfigFabric.js | 74 +++++++++++++++-- .../ReactFabricFragmentRefs-test.internal.js | 83 +++++++++++++++++++ .../src/ReactFiberTreeReflection.js | 8 +- .../ReactFeatureFlags.native-fb-dynamic.js | 1 + .../forks/ReactFeatureFlags.native-fb.js | 2 +- scripts/error-codes/codes.json | 3 +- 6 files changed, 159 insertions(+), 12 deletions(-) create mode 100644 packages/react-native-renderer/src/__tests__/ReactFabricFragmentRefs-test.internal.js diff --git a/packages/react-native-renderer/src/ReactFiberConfigFabric.js b/packages/react-native-renderer/src/ReactFiberConfigFabric.js index 151cd3c8cc2ae..1cf98193abcc7 100644 --- a/packages/react-native-renderer/src/ReactFiberConfigFabric.js +++ b/packages/react-native-renderer/src/ReactFiberConfigFabric.js @@ -24,6 +24,7 @@ import { } from 'react-reconciler/src/ReactEventPriorities'; import type {Fiber} from 'react-reconciler/src/ReactInternalTypes'; import {HostText} from 'react-reconciler/src/ReactWorkTags'; +import {traverseFragmentInstance} from 'react-reconciler/src/ReactFiberTreeReflection'; // Modules provided by RN: import { @@ -622,30 +623,91 @@ export function waitForCommitToBeReady(): null { return null; } -export type FragmentInstanceType = null; +export type FragmentInstanceType = { + _fragmentFiber: Fiber, + _observers: null | Set, + observeUsing: (observer: IntersectionObserver) => void, + unobserveUsing: (observer: IntersectionObserver) => void, +}; + +function FragmentInstance(this: FragmentInstanceType, fragmentFiber: Fiber) { + this._fragmentFiber = fragmentFiber; + this._observers = null; +} + +// $FlowFixMe[prop-missing] +FragmentInstance.prototype.observeUsing = function ( + this: FragmentInstanceType, + observer: IntersectionObserver, +): void { + if (this._observers === null) { + this._observers = new Set(); + } + this._observers.add(observer); + traverseFragmentInstance(this._fragmentFiber, observeChild, observer); +}; +function observeChild(instance: Instance, observer: IntersectionObserver) { + const publicInstance = getPublicInstance(instance); + if (publicInstance == null) { + throw new Error('Expected to find a host node. This is a bug in React.'); + } + // $FlowFixMe[incompatible-call] Element types are behind a flag in RN + observer.observe(publicInstance); + return false; +} +// $FlowFixMe[prop-missing] +FragmentInstance.prototype.unobserveUsing = function ( + this: FragmentInstanceType, + observer: IntersectionObserver, +): void { + if (this._observers === null || !this._observers.has(observer)) { + if (__DEV__) { + console.error( + 'You are calling unobserveUsing() with an observer that is not being observed with this fragment ' + + 'instance. First attach the observer with observeUsing()', + ); + } + } else { + this._observers.delete(observer); + traverseFragmentInstance(this._fragmentFiber, unobserveChild, observer); + } +}; +function unobserveChild(instance: Instance, observer: IntersectionObserver) { + const publicInstance = getPublicInstance(instance); + if (publicInstance == null) { + throw new Error('Expected to find a host node. This is a bug in React.'); + } + // $FlowFixMe[incompatible-call] Element types are behind a flag in RN + observer.unobserve(publicInstance); + return false; +} export function createFragmentInstance( fragmentFiber: Fiber, ): FragmentInstanceType { - return null; + return new (FragmentInstance: any)(fragmentFiber); } export function updateFragmentInstanceFiber( fragmentFiber: Fiber, instance: FragmentInstanceType, ): void { - // Noop + instance._fragmentFiber = fragmentFiber; } export function commitNewChildToFragmentInstance( - child: PublicInstance, + child: Instance, fragmentInstance: FragmentInstanceType, ): void { - // Noop + if (fragmentInstance._observers !== null) { + fragmentInstance._observers.forEach(observer => { + observeChild(child, observer); + }); + } } export function deleteChildFromFragmentInstance( - child: PublicInstance, + child: Instance, fragmentInstance: FragmentInstanceType, ): void { // Noop diff --git a/packages/react-native-renderer/src/__tests__/ReactFabricFragmentRefs-test.internal.js b/packages/react-native-renderer/src/__tests__/ReactFabricFragmentRefs-test.internal.js new file mode 100644 index 0000000000000..725b8d9de694f --- /dev/null +++ b/packages/react-native-renderer/src/__tests__/ReactFabricFragmentRefs-test.internal.js @@ -0,0 +1,83 @@ +/** + * 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'; + +let React; +let ReactFabric; +let createReactNativeComponentClass; +let act; +let View; +let Text; + +describe('Fabric FragmentRefs', () => { + beforeEach(() => { + jest.resetModules(); + + require('react-native/Libraries/ReactPrivate/InitializeNativeFabricUIManager'); + + React = require('react'); + ReactFabric = require('react-native-renderer/fabric'); + createReactNativeComponentClass = + require('react-native/Libraries/ReactPrivate/ReactNativePrivateInterface') + .ReactNativeViewConfigRegistry.register; + ({act} = require('internal-test-utils')); + View = createReactNativeComponentClass('RCTView', () => ({ + validAttributes: {nativeID: true}, + uiViewClassName: 'RCTView', + })); + Text = createReactNativeComponentClass('RCTText', () => ({ + validAttributes: {nativeID: true}, + uiViewClassName: 'RCTText', + })); + }); + + // @gate enableFragmentRefs + it('attaches a ref to Fragment', async () => { + const fragmentRef = React.createRef(); + + await act(() => + ReactFabric.render( + + + + Hi + + + , + 11, + null, + true, + ), + ); + + expect(fragmentRef.current).not.toBe(null); + }); + + // @gate enableFragmentRefs + it('accepts a ref callback', async () => { + let fragmentRef; + + await act(() => { + ReactFabric.render( + (fragmentRef = ref)}> + + Hi + + , + 11, + null, + true, + ); + }); + + expect(fragmentRef && fragmentRef._fragmentFiber).toBeTruthy(); + }); +}); diff --git a/packages/react-reconciler/src/ReactFiberTreeReflection.js b/packages/react-reconciler/src/ReactFiberTreeReflection.js index d682784f9a2aa..d799e2308ae47 100644 --- a/packages/react-reconciler/src/ReactFiberTreeReflection.js +++ b/packages/react-reconciler/src/ReactFiberTreeReflection.js @@ -345,9 +345,9 @@ export function doesFiberContain( return false; } -export function traverseFragmentInstance( +export function traverseFragmentInstance( fragmentFiber: Fiber, - fn: (Instance, A, B, C) => boolean, + fn: (I, A, B, C) => boolean, a: A, b: B, c: C, @@ -355,9 +355,9 @@ export function traverseFragmentInstance( traverseFragmentInstanceChildren(fragmentFiber.child, fn, a, b, c); } -function traverseFragmentInstanceChildren( +function traverseFragmentInstanceChildren( child: Fiber | null, - fn: (Instance, A, B, C) => boolean, + fn: (I, A, B, C) => boolean, a: A, b: B, c: C, diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js b/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js index e80b745587ba5..f1ced67c446d9 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js @@ -28,3 +28,4 @@ export const enableSiblingPrerendering = __VARIANT__; export const enableFastAddPropertiesInDiffing = __VARIANT__; export const enableLazyPublicInstanceInFabric = __VARIANT__; export const renameElementSymbol = __VARIANT__; +export const enableFragmentRefs = __VARIANT__; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index a13ae59e80ac5..6bc3f7b1d1e1f 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -30,6 +30,7 @@ export const { enableFastAddPropertiesInDiffing, enableLazyPublicInstanceInFabric, renameElementSymbol, + enableFragmentRefs, } = dynamicFlags; // The rest of the flags are static for better dead code elimination. @@ -84,7 +85,6 @@ export const enableGestureTransition = false; export const enableScrollEndPolyfill = true; export const enableSuspenseyImages = false; export const enableSrcObject = false; -export const enableFragmentRefs = false; export const ownerStackLimit = 1e4; // Flow magic to verify the exports of this file match the original version. diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 25e0ee802442d..d81f7489f99f6 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -543,5 +543,6 @@ "555": "Cannot requestFormReset() inside a startGestureTransition. There should be no side-effects associated with starting a Gesture until its Action is invoked. Move side-effects to the Action instead.", "556": "Expected prepareToHydrateHostActivityInstance() to never be called. This error is likely caused by a bug in React. Please file an issue.", "557": "Expected to have a hydrated activity instance. This error is likely caused by a bug in React. Please file an issue.", - "558": "Client rendering an Activity suspended it again. This is a bug in React." + "558": "Client rendering an Activity suspended it again. This is a bug in React.", + "559": "Expected to find a host node. This is a bug in React." } From fa8e3a251e7840fa220a1649dec616e72658473e Mon Sep 17 00:00:00 2001 From: "Sebastian \"Sebbie\" Silbermann" Date: Wed, 30 Apr 2025 19:51:40 +0200 Subject: [PATCH 2/4] [devtools] Restore all Transitions for Tree updates (#33042) --- .../src/devtools/views/Components/Element.js | 4 +- .../views/Components/OwnersListContext.js | 43 +++++++++++++++++-- .../devtools/views/Components/OwnersStack.js | 5 ++- .../src/devtools/views/Components/Tree.js | 4 +- .../devtools/views/Components/TreeContext.js | 3 +- 5 files changed, 50 insertions(+), 9 deletions(-) diff --git a/packages/react-devtools-shared/src/devtools/views/Components/Element.js b/packages/react-devtools-shared/src/devtools/views/Components/Element.js index 7854f4c99b3ae..71e0ebfbe9cbe 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/Element.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/Element.js @@ -23,6 +23,7 @@ import type {Element as ElementType} from 'react-devtools-shared/src/frontend/ty import styles from './Element.css'; import Icon from '../Icon'; +import {useChangeOwnerAction} from './OwnersListContext'; type Props = { data: ItemData, @@ -66,9 +67,10 @@ export default function Element({data, index, style}: Props): React.Node { warningCount: number, }>(errorsAndWarningsSubscription); + const changeOwnerAction = useChangeOwnerAction(); const handleDoubleClick = () => { if (id !== null) { - dispatch({type: 'SELECT_OWNER', payload: id}); + changeOwnerAction(id); } }; diff --git a/packages/react-devtools-shared/src/devtools/views/Components/OwnersListContext.js b/packages/react-devtools-shared/src/devtools/views/Components/OwnersListContext.js index f88bc7e9724be..060a5711626e8 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/OwnersListContext.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/OwnersListContext.js @@ -13,7 +13,7 @@ import * as React from 'react'; import {createContext, useCallback, useContext, useEffect} from 'react'; import {createResource} from '../../cache'; import {BridgeContext, StoreContext} from '../context'; -import {TreeStateContext} from './TreeContext'; +import {TreeDispatcherContext, TreeStateContext} from './TreeContext'; import {backendToFrontendSerializedElementMapper} from 'react-devtools-shared/src/utils'; import type {OwnersList} from 'react-devtools-shared/src/backend/types'; @@ -70,6 +70,43 @@ type Props = { children: React$Node, }; +function useChangeOwnerAction(): (nextOwnerID: number) => void { + const bridge = useContext(BridgeContext); + const store = useContext(StoreContext); + const treeAction = useContext(TreeDispatcherContext); + + return useCallback( + function changeOwnerAction(nextOwnerID: number) { + treeAction({type: 'SELECT_OWNER', payload: nextOwnerID}); + + const element = store.getElementByID(nextOwnerID); + if (element !== null) { + if (!inProgressRequests.has(element)) { + let resolveFn: + | ResolveFn + | (( + result: + | Promise> + | Array, + ) => void) = ((null: any): ResolveFn); + const promise = new Promise(resolve => { + resolveFn = resolve; + }); + + // $FlowFixMe[incompatible-call] found when upgrading Flow + inProgressRequests.set(element, {promise, resolveFn}); + } + + const rendererID = store.getRendererIDForElement(nextOwnerID); + if (rendererID !== null) { + bridge.send('getOwnersList', {id: nextOwnerID, rendererID}); + } + } + }, + [bridge, store], + ); +} + function OwnersListContextController({children}: Props): React.Node { const bridge = useContext(BridgeContext); const store = useContext(StoreContext); @@ -95,8 +132,6 @@ function OwnersListContextController({children}: Props): React.Node { if (element !== null) { const request = inProgressRequests.get(element); if (request != null) { - inProgressRequests.delete(element); - request.resolveFn( ownersList.owners === null ? null @@ -129,4 +164,4 @@ function OwnersListContextController({children}: Props): React.Node { ); } -export {OwnersListContext, OwnersListContextController}; +export {OwnersListContext, OwnersListContextController, useChangeOwnerAction}; diff --git a/packages/react-devtools-shared/src/devtools/views/Components/OwnersStack.js b/packages/react-devtools-shared/src/devtools/views/Components/OwnersStack.js index 0486b55c69db4..0fa5c0910bb6e 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/OwnersStack.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/OwnersStack.js @@ -20,7 +20,7 @@ import Button from '../Button'; import ButtonIcon from '../ButtonIcon'; import Toggle from '../Toggle'; import ElementBadges from './ElementBadges'; -import {OwnersListContext} from './OwnersListContext'; +import {OwnersListContext, useChangeOwnerAction} from './OwnersListContext'; import {TreeDispatcherContext, TreeStateContext} from './TreeContext'; import {useIsOverflowing} from '../hooks'; import {StoreContext} from '../context'; @@ -81,6 +81,7 @@ export default function OwnerStack(): React.Node { const read = useContext(OwnersListContext); const {ownerID} = useContext(TreeStateContext); const treeDispatch = useContext(TreeDispatcherContext); + const changeOwnerAction = useChangeOwnerAction(); const [state, dispatch] = useReducer(dialogReducer, { ownerID: null, @@ -116,7 +117,7 @@ export default function OwnerStack(): React.Node { type: 'UPDATE_SELECTED_INDEX', selectedIndex: index >= 0 ? index : 0, }); - treeDispatch({type: 'SELECT_OWNER', payload: owner.id}); + changeOwnerAction(owner.id); } else { dispatch({ type: 'UPDATE_SELECTED_INDEX', diff --git a/packages/react-devtools-shared/src/devtools/views/Components/Tree.js b/packages/react-devtools-shared/src/devtools/views/Components/Tree.js index d0fc0d924cd17..1ba61c52dd1a4 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/Tree.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/Tree.js @@ -38,6 +38,7 @@ import ButtonIcon from '../ButtonIcon'; import Button from '../Button'; import {logEvent} from 'react-devtools-shared/src/Logger'; import {useExtensionComponentsPanelVisibility} from 'react-devtools-shared/src/frontend/hooks/useExtensionComponentsPanelVisibility'; +import {useChangeOwnerAction} from './OwnersListContext'; // Never indent more than this number of pixels (even if we have the room). const DEFAULT_INDENTATION_SIZE = 12; @@ -217,13 +218,14 @@ export default function Tree(): React.Node { const handleBlur = useCallback(() => setTreeFocused(false), []); const handleFocus = useCallback(() => setTreeFocused(true), []); + const changeOwnerAction = useChangeOwnerAction(); const handleKeyPress = useCallback( (event: $FlowFixMe) => { switch (event.key) { case 'Enter': case ' ': if (inspectedElementID !== null) { - dispatch({type: 'SELECT_OWNER', payload: inspectedElementID}); + changeOwnerAction(inspectedElementID); } break; default: diff --git a/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js b/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js index fa1d619a93c28..46c76462d09d4 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/TreeContext.js @@ -148,6 +148,7 @@ const TreeStateContext: ReactContext = createContext(((null: any): StateContext)); TreeStateContext.displayName = 'TreeStateContext'; +// TODO: `dispatch` is an Action and should be named accordingly. const TreeDispatcherContext: ReactContext = createContext(((null: any): DispatcherContext)); TreeDispatcherContext.displayName = 'TreeDispatcherContext'; @@ -953,7 +954,7 @@ function TreeContextController({ return ( - + {children} From 9a52ad9fd9c6211d056942a455435c2804869854 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Wed, 30 Apr 2025 14:21:14 -0400 Subject: [PATCH 3/4] [Fizz] Remove globals from external runtime (#33065) We never emit any inline functions when we use external runtime so this global shouldn't be needed. --- .../src/server/ReactDOMServerExternalRuntime.js | 7 ------- 1 file changed, 7 deletions(-) diff --git a/packages/react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js b/packages/react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js index b839575952598..11ddbfd15c987 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js +++ b/packages/react-dom-bindings/src/server/ReactDOMServerExternalRuntime.js @@ -14,13 +14,6 @@ import { completeSegment, } from './fizz-instruction-set/ReactDOMFizzInstructionSetExternalRuntime'; -if (!window.$RC) { - // TODO: Eventually remove, we currently need to set these globals for - // compatibility with ReactDOMFizzInstructionSet - window.$RC = completeBoundary; - window.$RM = new Map(); -} - if (document.body != null) { if (document.readyState === 'loading') { installFizzInstrObserver(document.body); From 49ea8bf5698f017022517be79f5f53e6f6a4dad3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Wed, 30 Apr 2025 14:21:28 -0400 Subject: [PATCH 4/4] [Flight] Defer Elements if the parent chunk is too large (#33030) Same principle as #33029 but for Flight. We pretty aggressively create separate rows for things in Flight (every Server Component that's an async function create a microtask). However, sync Server Components and just plain Host Components are not. Plus we should ideally ideally inline more of the async ones in the same way Fizz does. This means that we can create rows that end up very large. Especially if all the data is already available. We can't show the parent content until the whole thing loads on the client. We don't really know where Suspense boundaries are for Flight but any Element is potentially a point that can be split. This heuristic counts roughly how much we've serialized to block the current chunk and once a limit is exceeded, we start deferring all Elements. That way they get outlined into future chunks that are later in the stream. Since they get replaced by Lazy references the parent can potentially get unblocked. This can help if you're trying to stream a very large document with a client nav for example. --- .../src/__tests__/ReactFlightDOMEdge-test.js | 116 ++++++++++++++++++ .../react-server/src/ReactFlightServer.js | 49 +++++++- 2 files changed, 162 insertions(+), 3 deletions(-) diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js index 8998a471cb863..5194913d2cb32 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js @@ -160,6 +160,61 @@ describe('ReactFlightDOMEdge', () => { }); } + function dripStream(input) { + const reader = input.getReader(); + let nextDrop = 0; + let controller = null; + let streamDone = false; + const buffer = []; + function flush() { + if (controller === null || nextDrop === 0) { + return; + } + while (buffer.length > 0 && nextDrop > 0) { + const nextChunk = buffer[0]; + if (nextChunk.byteLength <= nextDrop) { + nextDrop -= nextChunk.byteLength; + controller.enqueue(nextChunk); + buffer.shift(); + if (streamDone && buffer.length === 0) { + controller.done(); + } + } else { + controller.enqueue(nextChunk.subarray(0, nextDrop)); + buffer[0] = nextChunk.subarray(nextDrop); + nextDrop = 0; + } + } + } + const output = new ReadableStream({ + start(c) { + controller = c; + async function pump() { + for (;;) { + const {value, done} = await reader.read(); + if (done) { + streamDone = true; + break; + } + buffer.push(value); + flush(); + } + } + pump(); + }, + pull() {}, + cancel(reason) { + reader.cancel(reason); + }, + }); + function drip(n) { + nextDrop += n; + flush(); + } + + return [output, drip]; + } + async function readResult(stream) { const reader = stream.getReader(); let result = ''; @@ -576,6 +631,67 @@ describe('ReactFlightDOMEdge', () => { expect(serializedContent.length).toBeLessThan(150 + expectedDebugInfoSize); }); + it('should break up large sync components by outlining into streamable elements', async () => { + const paragraphs = []; + for (let i = 0; i < 20; i++) { + const text = + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris' + + 'porttitor tortor ac lectus faucibus, eget eleifend elit hendrerit.' + + 'Integer porttitor nisi in leo congue rutrum. Morbi sed ante posuere,' + + 'aliquam lorem ac, imperdiet orci. Duis malesuada gravida pharetra. Cras' + + 'facilisis arcu diam, id dictum lorem imperdiet a. Suspendisse aliquet' + + 'tempus tortor et ultricies. Aliquam libero velit, posuere tempus ante' + + 'sed, pellentesque tincidunt lorem. Nullam iaculis, eros a varius' + + 'aliquet, tortor felis tempor metus, nec cursus felis eros aliquam nulla.' + + 'Vivamus ut orci sed mauris congue lacinia. Cras eget blandit neque.' + + 'Pellentesque a massa in turpis ullamcorper volutpat vel at massa. Sed' + + 'ante est, auctor non diam non, vulputate ultrices metus. Maecenas dictum' + + 'fermentum quam id aliquam. Donec porta risus vitae pretium posuere.' + + 'Fusce facilisis eros in lacus tincidunt congue.' + + i; /* trick dedupe */ + paragraphs.push(

{text}

); + } + + const stream = await serverAct(() => + ReactServerDOMServer.renderToReadableStream(paragraphs), + ); + + const [stream2, drip] = dripStream(stream); + + // Allow some of the content through. + drip(5000); + + const result = await ReactServerDOMClient.createFromReadableStream( + stream2, + { + serverConsumerManifest: { + moduleMap: null, + moduleLoading: null, + }, + }, + ); + + // We should have resolved enough to be able to get the array even though some + // of the items inside are still lazy. + expect(result.length).toBe(20); + + // Unblock the rest + drip(Infinity); + + // Use the SSR render to resolve any lazy elements + const ssrStream = await serverAct(() => + ReactDOMServer.renderToReadableStream(result), + ); + const html = await readResult(ssrStream); + + const ssrStream2 = await serverAct(() => + ReactDOMServer.renderToReadableStream(paragraphs), + ); + const html2 = await readResult(ssrStream2); + + expect(html).toBe(html2); + }); + it('should be able to serialize any kind of typed array', async () => { const buffer = new Uint8Array([ 123, 4, 10, 5, 100, 255, 244, 45, 56, 67, 43, 124, 67, 89, 100, 20, diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index 826386f791833..aefcf5f6ee809 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -1600,6 +1600,29 @@ function renderClientElement( // The chunk ID we're currently rendering that we can assign debug data to. let debugID: null | number = null; +// Approximate string length of the currently serializing row. +// Used to power outlining heuristics. +let serializedSize = 0; +const MAX_ROW_SIZE = 3200; + +function deferTask(request: Request, task: Task): ReactJSONValue { + // Like outlineTask but instead the item is scheduled to be serialized + // after its parent in the stream. + const newTask = createTask( + request, + task.model, // the currently rendering element + task.keyPath, // unlike outlineModel this one carries along context + task.implicitSlot, + request.abortableTasks, + __DEV__ ? task.debugOwner : null, + __DEV__ ? task.debugStack : null, + __DEV__ ? task.debugTask : null, + ); + + pingTask(request, newTask); + return serializeLazyID(newTask.id); +} + function outlineTask(request: Request, task: Task): ReactJSONValue { const newTask = createTask( request, @@ -2393,6 +2416,8 @@ function renderModelDestructive( // Set the currently rendering model task.model = value; + serializedSize += parentPropertyName.length; + // Special Symbol, that's very common. if (value === REACT_ELEMENT_TYPE) { return '$'; @@ -2442,6 +2467,10 @@ function renderModelDestructive( const element: ReactElement = (value: any); + if (serializedSize > MAX_ROW_SIZE) { + return deferTask(request, task); + } + if (__DEV__) { const debugInfo: ?ReactDebugInfo = (value: any)._debugInfo; if (debugInfo) { @@ -2500,6 +2529,10 @@ function renderModelDestructive( return newChild; } case REACT_LAZY_TYPE: { + if (serializedSize > MAX_ROW_SIZE) { + return deferTask(request, task); + } + // Reset the task's thenable state before continuing. If there was one, it was // from suspending the lazy before. task.thenableState = null; @@ -2811,6 +2844,7 @@ function renderModelDestructive( throwTaintViolation(tainted.message); } } + serializedSize += value.length; // TODO: Maybe too clever. If we support URL there's no similar trick. if (value[value.length - 1] === 'Z') { // Possibly a Date, whose toJSON automatically calls toISOString @@ -3892,9 +3926,18 @@ function emitChunk( return; } // For anything else we need to try to serialize it using JSON. - // $FlowFixMe[incompatible-type] stringify can return null for undefined but we never do - const json: string = stringify(value, task.toJSON); - emitModelChunk(request, task.id, json); + // We stash the outer parent size so we can restore it when we exit. + const parentSerializedSize = serializedSize; + // We don't reset the serialized size counter from reentry because that indicates that we + // are outlining a model and we actually want to include that size into the parent since + // it will still block the parent row. It only restores to zero at the top of the stack. + try { + // $FlowFixMe[incompatible-type] stringify can return null for undefined but we never do + const json: string = stringify(value, task.toJSON); + emitModelChunk(request, task.id, json); + } finally { + serializedSize = parentSerializedSize; + } } function erroredTask(request: Request, task: Task, error: mixed): void {