[Flight] Emit debug info for a Server Component (#28272)

This adds a new DEV-only row type `D` for DebugInfo. If we see this in
prod, that's an error. It can contain extra debug information about the
Server Components (or Promises) that were compiled away during the
server render. It's DEV-only since this can contain sensitive
information (similar to errors) and since it'll be a lot of data, but
it's worth using the same stream for simplicity rather than a
side-channel.

In this first pass it's just the Server Component's name but I'll keep
adding more debug info to the stream, and it won't always just be a
Server Component's stack frame.

Each row can get more debug rows data streaming in as it resolves and
renders multiple server components in a row.

The data structure is just a side-channel and it would be perfectly fine
to ignore the D rows and it would behave the same as prod. With this
data structure though the data is associated with the row ID / chunk, so
you can't have inline meta data. This means that an inline Server
Component that doesn't get an ID otherwise will need to be outlined. The
way I outline Server Components is using a direct reference where it's
synchronous though so on the client side it behaves the same (i.e.
there's no lazy wrapper in this case).

In most cases the `_debugInfo` is on the Promises that we yield and we
also expose this on the `React.Lazy` wrappers. In the case where it's a
synchronous render it might attach this data to Elements or Arrays
(fragments) too.

In a future PR I'll wire this information up with Fiber to stash it in
the Fiber data structures so that DevTools can pick it up. This property
and the information in it is not limited to Server Components. The name
of the property that we look for probably shouldn't be `_debugInfo`
since it's semi-public. Should consider the name we use for that.

If it's a synchronous render that returns a string or number (text node)
then we don't have anywhere to attach them to. We could add a
`React.Lazy` wrapper for those but I chose to prioritize keeping the
data structure untouched. Can be useful if you use Server Components to
render data instead of React Nodes.

DiffTrain build for [b229f540e2](https://github.com/facebook/react/commit/b229f540e2da91370611945f9875e00a96196df6)
This commit is contained in:
sebmarkbage
2024-02-08 16:06:43 +00:00
parent 82dd48d0a1
commit c064e1665c
14 changed files with 218 additions and 29 deletions
@@ -1054,6 +1054,13 @@ if (__DEV__) {
enumerable: false,
writable: true,
value: false
}); // debugInfo contains Server Component debug information.
Object.defineProperty(element, "_debugInfo", {
configurable: false,
enumerable: false,
writable: true,
value: null
});
if (Object.freeze) {
@@ -1054,6 +1054,13 @@ if (__DEV__) {
enumerable: false,
writable: true,
value: false
}); // debugInfo contains Server Component debug information.
Object.defineProperty(element, "_debugInfo", {
configurable: false,
enumerable: false,
writable: true,
value: null
});
if (Object.freeze) {
+1 -1
View File
@@ -1 +1 @@
37d901e2b81e12d40df7012c6f8681b8272d2555
b229f540e2da91370611945f9875e00a96196df6
+15 -1
View File
@@ -24,7 +24,7 @@ if (__DEV__) {
) {
__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart(new Error());
}
var ReactVersion = "18.3.0-www-classic-965940b9";
var ReactVersion = "18.3.0-www-classic-28532002";
// ATTENTION
// When adding new symbols to this file,
@@ -772,6 +772,13 @@ if (__DEV__) {
enumerable: false,
writable: true,
value: false
}); // debugInfo contains Server Component debug information.
Object.defineProperty(element, "_debugInfo", {
configurable: false,
enumerable: false,
writable: true,
value: null
});
if (Object.freeze) {
@@ -1808,6 +1815,13 @@ if (__DEV__) {
enumerable: false,
writable: true,
value: false
}); // debugInfo contains Server Component debug information.
Object.defineProperty(element, "_debugInfo", {
configurable: false,
enumerable: false,
writable: true,
value: null
});
if (Object.freeze) {
+15 -1
View File
@@ -24,7 +24,7 @@ if (__DEV__) {
) {
__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart(new Error());
}
var ReactVersion = "18.3.0-www-modern-b425cc4f";
var ReactVersion = "18.3.0-www-modern-f7a0584c";
// ATTENTION
// When adding new symbols to this file,
@@ -772,6 +772,13 @@ if (__DEV__) {
enumerable: false,
writable: true,
value: false
}); // debugInfo contains Server Component debug information.
Object.defineProperty(element, "_debugInfo", {
configurable: false,
enumerable: false,
writable: true,
value: null
});
if (Object.freeze) {
@@ -1808,6 +1815,13 @@ if (__DEV__) {
enumerable: false,
writable: true,
value: false
}); // debugInfo contains Server Component debug information.
Object.defineProperty(element, "_debugInfo", {
configurable: false,
enumerable: false,
writable: true,
value: null
});
if (Object.freeze) {
+1 -1
View File
@@ -562,4 +562,4 @@ exports.useSyncExternalStore = function (
exports.useTransition = function () {
return ReactCurrentDispatcher.current.useTransition();
};
exports.version = "18.3.0-www-modern-2c5233b3";
exports.version = "18.3.0-www-modern-97aa8a4c";
@@ -17066,7 +17066,7 @@ Internals.Events = [
var devToolsConfig$jscomp$inline_1784 = {
findFiberByHostInstance: getClosestInstanceFromNode,
bundleType: 0,
version: "18.3.0-www-modern-5ed7b4c6",
version: "18.3.0-www-modern-da44ba18",
rendererPackageName: "react-dom"
};
var internals$jscomp$inline_2157 = {
@@ -17097,7 +17097,7 @@ var internals$jscomp$inline_2157 = {
scheduleRoot: null,
setRefreshHandler: null,
getCurrentFiber: null,
reconcilerVersion: "18.3.0-www-modern-5ed7b4c6"
reconcilerVersion: "18.3.0-www-modern-da44ba18"
};
if ("undefined" !== typeof __REACT_DEVTOOLS_GLOBAL_HOOK__) {
var hook$jscomp$inline_2158 = __REACT_DEVTOOLS_GLOBAL_HOOK__;
@@ -17525,4 +17525,4 @@ exports.useFormStatus = function () {
return ReactCurrentDispatcher$2.current.useHostTransitionStatus();
throw Error(formatProdErrorMessage(248));
};
exports.version = "18.3.0-www-modern-5ed7b4c6";
exports.version = "18.3.0-www-modern-da44ba18";
@@ -233,13 +233,18 @@ if (__DEV__) {
var RESOLVED_MODEL = "resolved_model";
var RESOLVED_MODULE = "resolved_module";
var INITIALIZED = "fulfilled";
var ERRORED = "rejected"; // $FlowFixMe[missing-this-annot]
var ERRORED = "rejected"; // Dev-only
// $FlowFixMe[missing-this-annot]
function Chunk(status, value, reason, response) {
this.status = status;
this.value = value;
this.reason = reason;
this._response = response;
{
this._debugInfo = null;
}
} // We subclass Promise.prototype so that we get other methods like .catch
Chunk.prototype = Object.create(Promise.prototype); // TODO: This doesn't return a new Promise chain unlike the real .then
@@ -537,6 +542,13 @@ if (__DEV__) {
enumerable: false,
writable: true,
value: true // This element has already been validated on the server.
}); // debugInfo contains Server Component debug information.
Object.defineProperty(element, "_debugInfo", {
configurable: false,
enumerable: false,
writable: true,
value: null
});
}
@@ -549,6 +561,13 @@ if (__DEV__) {
_payload: chunk,
_init: readChunk
};
{
// Ensure we have a live array to track future debug info.
var chunkDebugInfo = chunk._debugInfo || (chunk._debugInfo = []);
lazyType._debugInfo = chunkDebugInfo;
}
return lazyType;
}
@@ -768,7 +787,35 @@ if (__DEV__) {
switch (_chunk2.status) {
case INITIALIZED:
return _chunk2.value;
var chunkValue = _chunk2.value;
if (_chunk2._debugInfo) {
// If we have a direct reference to an object that was rendered by a synchronous
// server component, it might have some debug info about how it was rendered.
// We forward this to the underlying object. This might be a React Element or
// an Array fragment.
// If this was a string / number return value we lose the debug info. We choose
// that tradeoff to allow sync server components to return plain values and not
// use them as React Nodes necessarily. We could otherwise wrap them in a Lazy.
if (
typeof chunkValue === "object" &&
chunkValue !== null &&
(Array.isArray(chunkValue) ||
chunkValue.$$typeof === REACT_ELEMENT_TYPE) &&
!chunkValue._debugInfo
) {
// We should maybe use a unique symbol for arrays but this is a React owned array.
// $FlowFixMe[prop-missing]: This should be added to elements.
Object.defineProperty(chunkValue, "_debugInfo", {
configurable: false,
enumerable: false,
writable: true,
value: _chunk2._debugInfo
});
}
}
return chunkValue;
case PENDING:
case BLOCKED:
@@ -925,6 +972,12 @@ if (__DEV__) {
dispatchHint(code, hintModel);
}
function resolveDebugInfo(response, id, debugInfo) {
var chunk = getChunk(response, id);
var chunkDebugInfo = chunk._debugInfo || (chunk._debugInfo = []);
chunkDebugInfo.push(debugInfo);
}
function processFullRow(response, id, tag, buffer, chunk) {
var stringDecoder = response._stringDecoder;
var row = "";
@@ -972,6 +1025,15 @@ if (__DEV__) {
return;
}
case 68: /* "D" */
{
{
var debugInfo = JSON.parse(row);
resolveDebugInfo(response, id, debugInfo);
return;
}
}
case 80:
/* "P" */
// Fallthrough
@@ -506,6 +506,10 @@ function startReadingFromStream(response, stream) {
new Chunk("fulfilled", rowTag, null, rowLength)
);
break;
case 68:
throw Error(
"Failed to read a RSC payload created by a development version of React on the server while using a production version on the client. Always use matching versions on the server and the client."
);
default:
(i = rowLength._chunks),
(offset = i.get(rowID))
@@ -581,7 +581,10 @@ if (__DEV__) {
thenableState = prevThenableState;
}
function getThenableStateAfterSuspending() {
var state = thenableState;
// If you use() to Suspend this should always exist but if you throw a Promise instead,
// which is not really supported anymore, it will be empty. We use the empty set as a
// marker to know if this was a replay of the same component or first attempt.
var state = thenableState || createThenableState();
thenableState = null;
return state;
}
@@ -1314,6 +1317,23 @@ if (__DEV__) {
// component suspends again, the thenable state will be restored.
var prevThenableState = task.thenableState;
task.thenableState = null;
{
if (debugID === null) {
// We don't have a chunk to assign debug info. We need to outline this
// component to assign it an ID.
return outlineTask(request, task);
} else if (prevThenableState !== null);
else {
// This is a new component in the same task so we can emit more debug info.
var componentName = Component.displayName || Component.name || "";
request.pendingChunks++;
emitDebugChunk(request, debugID, {
name: componentName
});
}
}
prepareToUseHooksForComponent(prevThenableState); // The secondArg is always undefined in Server Components since refs error early.
var secondArg = undefined;
@@ -1421,6 +1441,28 @@ if (__DEV__) {
// or anything else too which we also get implicitly.
return element;
} // The chunk ID we're currently rendering that we can assign debug data to.
var debugID = null;
function outlineTask(request, task) {
var newTask = createTask(
request,
task.model, // the currently rendering element
task.keyPath, // unlike outlineModel this one carries along context
task.implicitSlot,
request.abortableTasks
);
retryTask(request, newTask);
if (newTask.status === COMPLETED) {
// We completed synchronously so we can refer to this by reference. This
// makes it behaves the same as prod during deserialization.
return serializeByValueID(newTask.id);
} // This didn't complete synchronously so it wouldn't have even if we didn't
// outline it, so this would reduce to a lazy reference even in prod.
return serializeLazyID(newTask.id);
}
function renderElement(request, task, type, key, ref, props) {
@@ -1445,7 +1487,7 @@ if (__DEV__) {
if (isClientReference(type)) {
// This is a reference to a Client Component.
return renderClientElement(task, type, key, props);
} // This is a server-side component.
} // This is a Server Component.
return renderFunctionComponent(request, task, key, type, props);
} else if (typeof type === "string") {
@@ -2257,6 +2299,13 @@ if (__DEV__) {
request.completedRegularChunks.push(processedChunk);
}
function emitDebugChunk(request, id, debugInfo) {
var json = stringify(debugInfo);
var row = serializeRowHeader("D", id) + json + "\n";
var processedChunk = stringToChunk(row);
request.completedRegularChunks.push(processedChunk);
}
var emptyRoot = {};
function retryTask(request, task) {
@@ -2265,11 +2314,18 @@ if (__DEV__) {
return;
}
var prevDebugID = debugID;
try {
// Track the root so we know that we have to emit this object even though it
// already has an ID. This is needed because we might see this object twice
// in the same toJSON if it is cyclic.
modelRoot = task.model; // We call the destructive form that mutates this task. That way if something
modelRoot = task.model;
if (true) {
// Track the ID of the current task so we can assign debug info to this id.
debugID = task.id;
} // We call the destructive form that mutates this task. That way if something
// suspends again, we can reuse the same task instead of spawning a new one.
var resolvedModel = renderModelDestructive(
@@ -2278,7 +2334,13 @@ if (__DEV__) {
emptyRoot,
"",
task.model
); // Track the root again for the resolved object.
);
if (true) {
// We're now past rendering this task and future renders will spawn new tasks for their
// debug info.
debugID = null;
} // Track the root again for the resolved object.
modelRoot = resolvedModel; // The keyPath resets at any terminal child node.
@@ -2326,6 +2388,10 @@ if (__DEV__) {
task.status = ERRORED;
var digest = logRecoverableError(request, x);
emitErrorChunk(request, task.id, digest, x);
} finally {
{
debugID = prevDebugID;
}
}
}
@@ -236,7 +236,7 @@ var currentRequest$1 = null,
thenableIndexCounter = 0,
thenableState = null;
function getThenableStateAfterSuspending() {
var state = thenableState;
var state = thenableState || [];
thenableState = null;
return state;
}
@@ -987,20 +987,21 @@ function retryTask(request, task) {
request.abortableTasks.delete(task);
task.status = 1;
} catch (thrownValue) {
(resolvedModel =
var x =
thrownValue === SuspenseException
? getSuspendedThenable()
: thrownValue),
"object" === typeof resolvedModel &&
null !== resolvedModel &&
"function" === typeof resolvedModel.then
? ((request = task.ping),
resolvedModel.then(request, request),
(task.thenableState = getThenableStateAfterSuspending()))
: (request.abortableTasks.delete(task),
(task.status = 4),
(resolvedModel = logRecoverableError(request, resolvedModel)),
emitErrorChunk(request, task.id, resolvedModel));
: thrownValue;
if ("object" === typeof x && null !== x && "function" === typeof x.then) {
var ping = task.ping;
x.then(ping, ping);
task.thenableState = getThenableStateAfterSuspending();
} else {
request.abortableTasks.delete(task);
task.status = 4;
var digest = logRecoverableError(request, x);
emitErrorChunk(request, task.id, digest);
}
} finally {
}
}
function performWork(request) {
@@ -564,6 +564,13 @@ if (__DEV__) {
enumerable: false,
writable: true,
value: false
}); // debugInfo contains Server Component debug information.
Object.defineProperty(element, "_debugInfo", {
configurable: false,
enumerable: false,
writable: true,
value: null
});
if (Object.freeze) {
@@ -1521,6 +1528,13 @@ if (__DEV__) {
enumerable: false,
writable: true,
value: false
}); // debugInfo contains Server Component debug information.
Object.defineProperty(element, "_debugInfo", {
configurable: false,
enumerable: false,
writable: true,
value: null
});
if (Object.freeze) {
@@ -2821,7 +2835,7 @@ if (__DEV__) {
console["error"](error);
};
var ReactVersion = "18.3.0-www-modern-b92e22f0";
var ReactVersion = "18.3.0-www-modern-61a7b928";
// Patch fetch
var Children = {
@@ -468,4 +468,4 @@ exports.useId = function () {
exports.useMemo = function (create, deps) {
return ReactCurrentDispatcher.current.useMemo(create, deps);
};
exports.version = "18.3.0-www-modern-0bbe43aa";
exports.version = "18.3.0-www-modern-8cbdba22";
@@ -26053,7 +26053,7 @@ if (__DEV__) {
return root;
}
var ReactVersion = "18.3.0-www-modern-e87be14d";
var ReactVersion = "18.3.0-www-modern-2a20f3fe";
// Might add PROFILE later.