@samitouri / QOS-React / commits / 383b2a1845

Use the Nearest Parent of an Errored Promise as its Owner (#29814)

Stacked on #29807. Conceptually the error's owner/task should ideally be captured when the Error constructor is called but neither `console.createTask` does this, nor do we override `Error` to capture our `owner`. So instead, we use the nearest parent as the owner/task of the error. This is usually the same thing when it's thrown from the same async component but not if you await a promise started from a different component/task. Before this stack the "owner" and "task" of a Lazy that errors was the nearest Fiber but if the thing erroring is a Server Component, we need to get that as the owner from the inner most part of debugInfo. To get the Task for that Server Component, we need to expose it on the ReactComponentInfo object. Unfortunately that makes the object not serializable so we need to special case this to exclude it from serialization. It gets restored again on the client. Before (Shell): <img width="813" alt="Screenshot 2024-06-06 at 5 16 20 PM" src="https://github.com/facebook/react/assets/63648/7da2d4c9-539b-494e-ba63-1abdc58ff13c"> After (App): <img width="811" alt="Screenshot 2024-06-08 at 12 29 23 AM" src="https://github.com/facebook/react/assets/63648/dbf40bd7-c24d-4200-81a6-5018bef55f6d">

Sebastian Markbåge committed Jun 11, 2024 at 18:10 UTC 383b2a18456215d2d3ec46f33c0c912e84efa08f
7 files changed +105 -15
.eslintrc.js
+1 -1
@@ -245,7 +245,7 @@ module.exports = {
245 },
246 ],
247 'no-shadow': ERROR,
248 - 'no-unused-vars': [ERROR, {args: 'none'}],
248 + 'no-unused-vars': [ERROR, {args: 'none', ignoreRestSiblings: true}],
249 'no-use-before-define': OFF,
250 'no-useless-concat': OFF,
251 quotes: [ERROR, 'single', {avoidEscape: true, allowTemplateLiterals: true}],
packages/react-client/src/ReactFlightClient.js
+10 -10
@@ -162,6 +162,7 @@ type InitializedStreamChunk<
162 value: T,
163 reason: FlightStreamController,
164 _response: Response,
165 + _debugInfo?: null | ReactDebugInfo,
166 then(resolve: (ReadableStream) => mixed, reject?: (mixed) => mixed): void,
167 };
168 type ErroredChunk<T> = {
@@ -1710,11 +1711,6 @@ function resolveHint<Code: HintCode>(
1711 const supportsCreateTask =
1712 __DEV__ && enableOwnerStacks && !!(console: any).createTask;
1713
1713 -const taskCache: null | WeakMap<
1714 - ReactComponentInfo | ReactAsyncInfo,
1715 - ConsoleTask,
1716 -> = supportsCreateTask ? new WeakMap() : null;
1717 -
1714 type FakeFunction<T> = (() => T) => T;
1715 const fakeFunctionCache: Map<string, FakeFunction<any>> = __DEV__
1716 ? new Map()
@@ -1834,12 +1830,12 @@ function initializeFakeTask(
1830 response: Response,
1831 debugInfo: ReactComponentInfo | ReactAsyncInfo,
1832 ): null | ConsoleTask {
1837 - if (taskCache === null || typeof debugInfo.stack !== 'string') {
1833 + if (!supportsCreateTask || typeof debugInfo.stack !== 'string') {
1834 return null;
1835 }
1836 const componentInfo: ReactComponentInfo = (debugInfo: any); // Refined
1837 const stack: string = debugInfo.stack;
1842 - const cachedEntry = taskCache.get((componentInfo: any));
1838 + const cachedEntry = componentInfo.task;
1839 if (cachedEntry !== undefined) {
1840 return cachedEntry;
1841 }
@@ -1856,16 +1852,20 @@ function initializeFakeTask(
1852 );
1853 const callStack = buildFakeCallStack(response, stack, createTaskFn);
1854
1855 + let componentTask;
1856 if (ownerTask === null) {
1857 const rootTask = response._debugRootTask;
1858 if (rootTask != null) {
1862 - return rootTask.run(callStack);
1859 + componentTask = rootTask.run(callStack);
1860 } else {
1864 - return callStack();
1861 + componentTask = callStack();
1862 }
1863 } else {
1867 - return ownerTask.run(callStack);
1864 + componentTask = ownerTask.run(callStack);
1865 }
1866 + // $FlowFixMe[cannot-write]: We consider this part of initialization.
1867 + componentInfo.task = componentTask;
1868 + return componentTask;
1869 }
1870
1871 function resolveDebugInfo(
packages/react-client/src/__tests__/ReactFlight-test.js
+16 -3
@@ -27,14 +27,27 @@ function normalizeCodeLocInfo(str) {
27 );
28 }
29
30 +function normalizeComponentInfo(debugInfo) {
31 + if (typeof debugInfo.stack === 'string') {
32 + const {task, ...copy} = debugInfo;
33 + copy.stack = normalizeCodeLocInfo(debugInfo.stack);
34 + if (debugInfo.owner) {
35 + copy.owner = normalizeComponentInfo(debugInfo.owner);
36 + }
37 + return copy;
38 + } else {
39 + return debugInfo;
40 + }
41 +}
42 +
43 function getDebugInfo(obj) {
44 const debugInfo = obj._debugInfo;
45 if (debugInfo) {
46 + const copy = [];
47 for (let i = 0; i < debugInfo.length; i++) {
34 - if (typeof debugInfo[i].stack === 'string') {
35 - debugInfo[i].stack = normalizeCodeLocInfo(debugInfo[i].stack);
36 - }
48 + copy.push(normalizeComponentInfo(debugInfo[i]));
49 }
50 + return copy;
51 }
52 return debugInfo;
53 }
packages/react-reconciler/src/ReactChildFiber.js
+23 -1
@@ -48,6 +48,7 @@ import {
48 enableRefAsProp,
49 enableAsyncIterableChildren,
50 disableLegacyMode,
51 + enableOwnerStacks,
52 } from 'shared/ReactFeatureFlags';
53
54 import {
@@ -1959,7 +1960,28 @@ function createChildReconciler(
1960 const throwFiber = createFiberFromThrow(x, returnFiber.mode, lanes);
1961 throwFiber.return = returnFiber;
1962 if (__DEV__) {
1962 - throwFiber._debugInfo = currentDebugInfo;
1963 + const debugInfo = (throwFiber._debugInfo = currentDebugInfo);
1964 + // Conceptually the error's owner/task should ideally be captured when the
1965 + // Error constructor is called but neither console.createTask does this,
1966 + // nor do we override them to capture our `owner`. So instead, we use the
1967 + // nearest parent as the owner/task of the error. This is usually the same
1968 + // thing when it's thrown from the same async component but not if you await
1969 + // a promise started from a different component/task.
1970 + throwFiber._debugOwner = returnFiber._debugOwner;
1971 + if (enableOwnerStacks) {
1972 + throwFiber._debugTask = returnFiber._debugTask;
1973 + }
1974 + if (debugInfo != null) {
1975 + for (let i = debugInfo.length - 1; i >= 0; i--) {
1976 + if (typeof debugInfo[i].stack === 'string') {
1977 + throwFiber._debugOwner = (debugInfo[i]: any);
1978 + if (enableOwnerStacks) {
1979 + throwFiber._debugTask = debugInfo[i].task;
1980 + }
1981 + break;
1982 + }
1983 + }
1984 + }
1985 }
1986 return throwFiber;
1987 } finally {
packages/react-reconciler/src/getComponentNameFromFiber.js
+21
@@ -44,6 +44,7 @@ import {
44 LegacyHiddenComponent,
45 CacheComponent,
46 TracingMarkerComponent,
47 + Throw,
48 } from 'react-reconciler/src/ReactWorkTags';
49 import getComponentNameFromType from 'shared/getComponentNameFromType';
50 import {REACT_STRICT_MODE_TYPE} from 'shared/ReactSymbols';
@@ -160,6 +161,26 @@ export default function getComponentNameFromFiber(fiber: Fiber): string | null {
161 if (enableLegacyHidden) {
162 return 'LegacyHidden';
163 }
164 + break;
165 + case Throw: {
166 + if (__DEV__) {
167 + // For an error in child position we use the of the inner most parent component.
168 + // Whether a Server Component or the parent Fiber.
169 + const debugInfo = fiber._debugInfo;
170 + if (debugInfo != null) {
171 + for (let i = debugInfo.length - 1; i >= 0; i--) {
172 + if (typeof debugInfo[i].name === 'string') {
173 + return debugInfo[i].name;
174 + }
175 + }
176 + }
177 + if (fiber.return === null) {
178 + return null;
179 + }
180 + return getComponentNameFromFiber(fiber.return);
181 + }
182 + return null;
183 + }
184 }
185
186 return null;
packages/react-server/src/ReactFlightServer.js
+33
@@ -352,6 +352,7 @@ export type ReactClientValue =
352 // subtype, so the receiver can only accept once of these.
353 | React$Element<string>
354 | React$Element<ClientReference<any> & any>
355 + | ReactComponentInfo
356 | string
357 | boolean
358 | number
@@ -2462,6 +2463,32 @@ function renderModelDestructive(
2463 );
2464 }
2465 if (__DEV__) {
2466 + if (
2467 + // TODO: We don't currently have a brand check on ReactComponentInfo. Reconsider.
2468 + typeof value.task === 'object' &&
2469 + value.task !== null &&
2470 + // $FlowFixMe[method-unbinding]
2471 + typeof value.task.run === 'function' &&
2472 + typeof value.name === 'string' &&
2473 + typeof value.env === 'string' &&
2474 + value.owner !== undefined &&
2475 + (enableOwnerStacks
2476 + ? typeof (value: any).stack === 'string'
2477 + : typeof (value: any).stack === 'undefined')
2478 + ) {
2479 + // This looks like a ReactComponentInfo. We can't serialize the ConsoleTask object so we
2480 + // need to omit it before serializing.
2481 + const componentDebugInfo = {
2482 + name: value.name,
2483 + env: value.env,
2484 + owner: value.owner,
2485 + };
2486 + if (enableOwnerStacks) {
2487 + (componentDebugInfo: any).stack = (value: any).stack;
2488 + }
2489 + return (componentDebugInfo: any);
2490 + }
2491 +
2492 if (objectName(value) !== 'Object') {
2493 console.error(
2494 'Only plain objects can be passed to Client Components from Server Components. ' +
@@ -3231,6 +3258,12 @@ function forwardDebugInfo(
3258 ) {
3259 for (let i = 0; i < debugInfo.length; i++) {
3260 request.pendingChunks++;
3261 + if (typeof debugInfo[i].name === 'string') {
3262 + // We outline this model eagerly so that we can refer to by reference as an owner.
3263 + // If we had a smarter way to dedupe we might not have to do this if there ends up
3264 + // being no references to this as an owner.
3265 + outlineModel(request, debugInfo[i]);
3266 + }
3267 emitDebugChunk(request, id, debugInfo[i]);
3268 }
3269 }
packages/shared/ReactTypes.js
+1
@@ -183,6 +183,7 @@ export type ReactComponentInfo = {
183 +env?: string,
184 +owner?: null | ReactComponentInfo,
185 +stack?: null | string,
186 + +task?: null | ConsoleTask,
187 };
188
189 export type ReactAsyncInfo = {