@samitouri / QOS-React / commits / 6928bf2f7c

[Flight] Log Server Component into Performance Track (#31729)

<img width="966" alt="Screenshot 2024-12-10 at 10 49 19 PM" src="https://github.com/user-attachments/assets/27a21bdf-86b9-4203-893b-89523e698138"> This emits a tree view visualization of the timing information for each Server Component provided in the RSC payload. The unique thing about this visualization is that the end time of each Server Component spans the end of the last child. Now what is conceptually a blocking child is kind of undefined in RSC. E.g. if you're not using a Promise on the client, or if it is wrapped in Suspense, is it really blocking the parent? Here I reconstruct parent-child relationship by which chunks reference other chunks. A child can belong to more than one parent like when we dedupe the result of a Server Component. Then I wait until the whole RSC payload has streamed in, and then I traverse the tree collecting the end time from children as I go and emit the `performance.measure()` calls on the way up. There's more work for this visualization in follow ups but this is the basics. For example, since the Server Component time span includes async work it's possible for siblings to execute their span in parallel (Foo and Bar in the screenshot are parallel siblings). To deal with this we need to spawn parallel work into separate tracks. Each one can be deep due to large trees. This can makes this type of visualization unwieldy when you have a lot of parallelism. Therefore I also plan another flatter Timeline visualization in a follow up.

Sebastian Markbåge committed Dec 12, 2024 at 14:03 UTC 6928bf2f7c7375ed9038a7b30d5b4760bb57055a
3 files changed +190 -8
fixtures/flight/src/App.js
+17 -1
@@ -20,12 +20,26 @@ import {like, greet, increment} from './actions.js';
20 import {getServerState} from './ServerState.js';
21
22 const promisedText = new Promise(resolve =>
23 - setTimeout(() => resolve('deferred text'), 100)
23 + setTimeout(() => resolve('deferred text'), 50)
24 );
25
26 +function Foo({children}) {
27 + return <div>{children}</div>;
28 +}
29 +
30 +function Bar({children}) {
31 + return <div>{children}</div>;
32 +}
33 +
34 +async function ServerComponent() {
35 + await new Promise(resolve => setTimeout(() => resolve('deferred text'), 50));
36 +}
37 +
38 export default async function App({prerender}) {
39 const res = await fetch('http://localhost:3001/todos');
40 const todos = await res.json();
41 +
42 + const dedupedChild = <ServerComponent />;
43 return (
44 <html lang="en">
45 <head>
@@ -66,6 +80,8 @@ export default async function App({prerender}) {
80 </div>
81 <Client />
82 <Note />
83 + <Foo>{dedupedChild}</Foo>
84 + <Bar>{dedupedChild}</Bar>
85 </Container>
86 </body>
87 </html>
packages/react-client/src/ReactFlightClient.js
+117 -7
@@ -71,6 +71,8 @@ import {createBoundServerReference} from './ReactFlightReplyClient';
71
72 import {readTemporaryReference} from './ReactFlightTemporaryReferences';
73
74 +import {logComponentRender} from './ReactFlightPerformanceTrack';
75 +
76 import {
77 REACT_LAZY_TYPE,
78 REACT_ELEMENT_TYPE,
@@ -124,6 +126,10 @@ export type JSONValue =
126 | {+[key: string]: JSONValue}
127 | $ReadOnlyArray<JSONValue>;
128
129 +type ProfilingResult = {
130 + endTime: number,
131 +};
132 +
133 const ROW_ID = 0;
134 const ROW_TAG = 1;
135 const ROW_LENGTH = 2;
@@ -144,7 +150,8 @@ type PendingChunk<T> = {
150 value: null | Array<(T) => mixed>,
151 reason: null | Array<(mixed) => mixed>,
152 _response: Response,
147 - _debugInfo?: null | ReactDebugInfo,
153 + _children: Array<SomeChunk<any>> | ProfilingResult, // Profiling-only
154 + _debugInfo?: null | ReactDebugInfo, // DEV-only
155 then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
156 };
157 type BlockedChunk<T> = {
@@ -152,7 +159,8 @@ type BlockedChunk<T> = {
159 value: null | Array<(T) => mixed>,
160 reason: null | Array<(mixed) => mixed>,
161 _response: Response,
155 - _debugInfo?: null | ReactDebugInfo,
162 + _children: Array<SomeChunk<any>> | ProfilingResult, // Profiling-only
163 + _debugInfo?: null | ReactDebugInfo, // DEV-only
164 then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
165 };
166 type ResolvedModelChunk<T> = {
@@ -160,7 +168,8 @@ type ResolvedModelChunk<T> = {
168 value: UninitializedModel,
169 reason: null,
170 _response: Response,
163 - _debugInfo?: null | ReactDebugInfo,
171 + _children: Array<SomeChunk<any>> | ProfilingResult, // Profiling-only
172 + _debugInfo?: null | ReactDebugInfo, // DEV-only
173 then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
174 };
175 type ResolvedModuleChunk<T> = {
@@ -168,7 +177,8 @@ type ResolvedModuleChunk<T> = {
177 value: ClientReference<T>,
178 reason: null,
179 _response: Response,
171 - _debugInfo?: null | ReactDebugInfo,
180 + _children: Array<SomeChunk<any>> | ProfilingResult, // Profiling-only
181 + _debugInfo?: null | ReactDebugInfo, // DEV-only
182 then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
183 };
184 type InitializedChunk<T> = {
@@ -176,7 +186,8 @@ type InitializedChunk<T> = {
186 value: T,
187 reason: null | FlightStreamController,
188 _response: Response,
179 - _debugInfo?: null | ReactDebugInfo,
189 + _children: Array<SomeChunk<any>> | ProfilingResult, // Profiling-only
190 + _debugInfo?: null | ReactDebugInfo, // DEV-only
191 then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
192 };
193 type InitializedStreamChunk<
@@ -186,7 +197,8 @@ type InitializedStreamChunk<
197 value: T,
198 reason: FlightStreamController,
199 _response: Response,
189 - _debugInfo?: null | ReactDebugInfo,
200 + _children: Array<SomeChunk<any>> | ProfilingResult, // Profiling-only
201 + _debugInfo?: null | ReactDebugInfo, // DEV-only
202 then(resolve: (ReadableStream) => mixed, reject?: (mixed) => mixed): void,
203 };
204 type ErroredChunk<T> = {
@@ -194,7 +206,8 @@ type ErroredChunk<T> = {
206 value: null,
207 reason: mixed,
208 _response: Response,
197 - _debugInfo?: null | ReactDebugInfo,
209 + _children: Array<SomeChunk<any>> | ProfilingResult, // Profiling-only
210 + _debugInfo?: null | ReactDebugInfo, // DEV-only
211 then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
212 };
213 type SomeChunk<T> =
@@ -216,6 +229,9 @@ function ReactPromise(
229 this.value = value;
230 this.reason = reason;
231 this._response = response;
232 + if (enableProfilerTimer && enableComponentPerformanceTrack) {
233 + this._children = [];
234 + }
235 if (__DEV__) {
236 this._debugInfo = null;
237 }
@@ -548,9 +564,11 @@ type InitializationHandler = {
564 errored: boolean,
565 };
566 let initializingHandler: null | InitializationHandler = null;
567 +let initializingChunk: null | BlockedChunk<any> = null;
568
569 function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
570 const prevHandler = initializingHandler;
571 + const prevChunk = initializingChunk;
572 initializingHandler = null;
573
574 const resolvedModel = chunk.value;
@@ -563,6 +581,10 @@ function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
581 cyclicChunk.value = null;
582 cyclicChunk.reason = null;
583
584 + if (enableProfilerTimer && enableComponentPerformanceTrack) {
585 + initializingChunk = cyclicChunk;
586 + }
587 +
588 try {
589 const value: T = parseModel(chunk._response, resolvedModel);
590 // Invoke any listeners added while resolving this model. I.e. cyclic
@@ -595,6 +617,9 @@ function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
617 erroredChunk.reason = error;
618 } finally {
619 initializingHandler = prevHandler;
620 + if (enableProfilerTimer && enableComponentPerformanceTrack) {
621 + initializingChunk = prevChunk;
622 + }
623 }
624 }
625
@@ -622,6 +647,9 @@ export function reportGlobalError(response: Response, error: Error): void {
647 triggerErrorOnChunk(chunk, error);
648 }
649 });
650 + if (enableProfilerTimer && enableComponentPerformanceTrack) {
651 + flushComponentPerformance(getChunk(response, 0));
652 + }
653 }
654
655 function nullRefGetter() {
@@ -1210,6 +1238,11 @@ function getOutlinedModel<T>(
1238 const path = reference.split(':');
1239 const id = parseInt(path[0], 16);
1240 const chunk = getChunk(response, id);
1241 + if (enableProfilerTimer && enableComponentPerformanceTrack) {
1242 + if (initializingChunk !== null && isArray(initializingChunk._children)) {
1243 + initializingChunk._children.push(chunk);
1244 + }
1245 + }
1246 switch (chunk.status) {
1247 case RESOLVED_MODEL:
1248 initializeModelChunk(chunk);
@@ -1359,6 +1392,14 @@ function parseModelString(
1392 // Lazy node
1393 const id = parseInt(value.slice(2), 16);
1394 const chunk = getChunk(response, id);
1395 + if (enableProfilerTimer && enableComponentPerformanceTrack) {
1396 + if (
1397 + initializingChunk !== null &&
1398 + isArray(initializingChunk._children)
1399 + ) {
1400 + initializingChunk._children.push(chunk);
1401 + }
1402 + }
1403 // We create a React.lazy wrapper around any lazy values.
1404 // When passed into React, we'll know how to suspend on this.
1405 return createLazyChunkWrapper(chunk);
@@ -1371,6 +1412,14 @@ function parseModelString(
1412 }
1413 const id = parseInt(value.slice(2), 16);
1414 const chunk = getChunk(response, id);
1415 + if (enableProfilerTimer && enableComponentPerformanceTrack) {
1416 + if (
1417 + initializingChunk !== null &&
1418 + isArray(initializingChunk._children)
1419 + ) {
1420 + initializingChunk._children.push(chunk);
1421 + }
1422 + }
1423 return chunk;
1424 }
1425 case 'S': {
@@ -2704,6 +2753,67 @@ function resolveTypedArray(
2753 resolveBuffer(response, id, view);
2754 }
2755
2756 +function flushComponentPerformance(root: SomeChunk<any>): number {
2757 + if (!enableProfilerTimer || !enableComponentPerformanceTrack) {
2758 + return 0;
2759 + }
2760 + // Write performance.measure() entries for Server Components in tree order.
2761 + // This must be done at the end to collect the end time from the whole tree.
2762 + if (!isArray(root._children)) {
2763 + // We have already written this chunk. If this was a cycle, then this will
2764 + // be -Infinity and it won't contribute to the parent end time.
2765 + // If this was already emitted by another sibling then we reused the same
2766 + // chunk in two places. We should extend the current end time as if it was
2767 + // rendered as part of this tree.
2768 + const previousResult: ProfilingResult = root._children;
2769 + return previousResult.endTime;
2770 + }
2771 + const children = root._children;
2772 + if (root.status === RESOLVED_MODEL) {
2773 + // If the model is not initialized by now, do that now so we can find its
2774 + // children. This part is a little sketchy since it significantly changes
2775 + // the performance characteristics of the app by profiling.
2776 + initializeModelChunk(root);
2777 + }
2778 + const result: ProfilingResult = {endTime: -Infinity};
2779 + root._children = result;
2780 + let childrenEndTime = -Infinity;
2781 + for (let i = 0; i < children.length; i++) {
2782 + const childEndTime = flushComponentPerformance(children[i]);
2783 + if (childEndTime > childrenEndTime) {
2784 + childrenEndTime = childEndTime;
2785 + }
2786 + }
2787 + const debugInfo = root._debugInfo;
2788 + if (debugInfo) {
2789 + let endTime = 0;
2790 + for (let i = debugInfo.length - 1; i >= 0; i--) {
2791 + const info = debugInfo[i];
2792 + if (typeof info.time === 'number') {
2793 + endTime = info.time;
2794 + if (endTime > childrenEndTime) {
2795 + childrenEndTime = endTime;
2796 + }
2797 + }
2798 + if (typeof info.name === 'string' && i > 0) {
2799 + // $FlowFixMe: Refined.
2800 + const componentInfo: ReactComponentInfo = info;
2801 + const startTimeInfo = debugInfo[i - 1];
2802 + if (typeof startTimeInfo.time === 'number') {
2803 + const startTime = startTimeInfo.time;
2804 + logComponentRender(
2805 + componentInfo,
2806 + startTime,
2807 + endTime,
2808 + childrenEndTime,
2809 + );
2810 + }
2811 + }
2812 + }
2813 + }
2814 + return (result.endTime = childrenEndTime);
2815 +}
2816 +
2817 function processFullBinaryRow(
2818 response: Response,
2819 id: number,
packages/react-client/src/ReactFlightPerformanceTrack.js new
+56
@@ -0,0 +1,56 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import type {ReactComponentInfo} from 'shared/ReactTypes';
11 +
12 +import {enableProfilerTimer} from 'shared/ReactFeatureFlags';
13 +
14 +const supportsUserTiming =
15 + enableProfilerTimer &&
16 + typeof performance !== 'undefined' &&
17 + // $FlowFixMe[method-unbinding]
18 + typeof performance.measure === 'function';
19 +
20 +const COMPONENTS_TRACK = 'Server Components ⚛';
21 +
22 +// Reused to avoid thrashing the GC.
23 +const reusableComponentDevToolDetails = {
24 + color: 'primary',
25 + track: COMPONENTS_TRACK,
26 +};
27 +const reusableComponentOptions = {
28 + start: -0,
29 + end: -0,
30 + detail: {
31 + devtools: reusableComponentDevToolDetails,
32 + },
33 +};
34 +
35 +export function logComponentRender(
36 + componentInfo: ReactComponentInfo,
37 + startTime: number,
38 + endTime: number,
39 + childrenEndTime: number,
40 +): void {
41 + if (supportsUserTiming && childrenEndTime >= 0) {
42 + const name = componentInfo.name;
43 + const selfTime = endTime - startTime;
44 + reusableComponentDevToolDetails.color =
45 + selfTime < 0.5
46 + ? 'primary-light'
47 + : selfTime < 50
48 + ? 'primary'
49 + : selfTime < 500
50 + ? 'primary-dark'
51 + : 'error';
52 + reusableComponentOptions.start = startTime < 0 ? 0 : startTime;
53 + reusableComponentOptions.end = childrenEndTime;
54 + performance.measure(name, reusableComponentOptions);
55 + }
56 +}