Skip to content

Commit

Permalink
[Flight] Emit debug info for a Server Component (#28272)
Browse files Browse the repository at this point in the history
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 [b229f54](b229f54)
  • Loading branch information
sebmarkbage committed Feb 8, 2024
1 parent 82dd48d commit c064e16
Show file tree
Hide file tree
Showing 14 changed files with 218 additions and 29 deletions.
7 changes: 7 additions & 0 deletions compiled/facebook-www/JSXDEVRuntime-dev.classic.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
7 changes: 7 additions & 0 deletions compiled/facebook-www/JSXDEVRuntime-dev.modern.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion compiled/facebook-www/REVISION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
37d901e2b81e12d40df7012c6f8681b8272d2555
b229f540e2da91370611945f9875e00a96196df6
16 changes: 15 additions & 1 deletion compiled/facebook-www/React-dev.classic.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
16 changes: 15 additions & 1 deletion compiled/facebook-www/React-dev.modern.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion compiled/facebook-www/React-prod.modern.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
6 changes: 3 additions & 3 deletions compiled/facebook-www/ReactDOMTesting-prod.modern.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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__;
Expand Down Expand Up @@ -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";
66 changes: 64 additions & 2 deletions compiled/facebook-www/ReactFlightDOMClient-dev.modern.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
});
}

Expand All @@ -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;
}

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 = "";
Expand Down Expand Up @@ -972,6 +1025,15 @@ if (__DEV__) {
return;
}

case 68: /* "D" */
{
{
var debugInfo = JSON.parse(row);
resolveDebugInfo(response, id, debugInfo);
return;
}
}

case 80:
/* "P" */
// Fallthrough
Expand Down
4 changes: 4 additions & 0 deletions compiled/facebook-www/ReactFlightDOMClient-prod.modern.js
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
74 changes: 70 additions & 4 deletions compiled/facebook-www/ReactFlightDOMServer-dev.modern.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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") {
Expand Down Expand Up @@ -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) {
Expand All @@ -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(
Expand All @@ -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.

Expand Down Expand Up @@ -2326,6 +2388,10 @@ if (__DEV__) {
task.status = ERRORED;
var digest = logRecoverableError(request, x);
emitErrorChunk(request, task.id, digest, x);
} finally {
{
debugID = prevDebugID;
}
}
}

Expand Down
Loading

0 comments on commit c064e16

Please sign in to comment.