From bbea677b77ebf5d696623e2f634c69744eaf9d86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Tue, 8 Jul 2025 10:49:25 -0400 Subject: [PATCH] [Flight] Lazy load objects from the debug channel (#33728) When a debug channel is available, we now allow objects to be lazily requested though the debug channel and only then will the server send it. The client will actually eagerly ask for the next level of objects once it parses its payload. That way those objects have likely loaded by the time you actually expand that deep e.g. in the console repl. This is needed since the console repl is synchronous when you ask it to invoke getters. Each level is lazily parsed which means that we don't parse the next level even though we eagerly loaded it. We parse it once the getter is invoked (in Chrome DevTools you have to click a little `(...)` to invoke the getter). When the getter is invoked, the chunk is initialized and parsed. This then causes the next level to be asked for through the debug channel. Ensuring that if you expand one more level you can do so synchronously. Currently debug chunks are eagerly parsed, which means that if you have things like server component props that are lazy they can end up being immediately asked for, but I'm trying to move to make the debug chunks lazy. --- fixtures/flight/src/App.js | 59 +++++++++++++++++++ .../react-client/src/ReactFlightClient.js | 49 ++++++++++++++- .../react-server/src/ReactFlightServer.js | 11 +++- 3 files changed, 115 insertions(+), 4 deletions(-) diff --git a/fixtures/flight/src/App.js b/fixtures/flight/src/App.js index b73162847d..935f77fa96 100644 --- a/fixtures/flight/src/App.js +++ b/fixtures/flight/src/App.js @@ -120,10 +120,69 @@ async function ServerComponent({noCache}) { return await fetchThirdParty(noCache); } +let veryDeepObject = [ + { + bar: { + baz: { + a: {}, + }, + }, + }, + { + bar: { + baz: { + a: {}, + }, + }, + }, + { + bar: { + baz: { + a: {}, + }, + }, + }, + { + bar: { + baz: { + a: { + b: { + c: { + d: { + e: { + f: { + g: { + h: { + i: { + j: { + k: { + l: { + m: { + yay: 'You reached the end', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, +]; + export default async function App({prerender, noCache}) { const res = await fetch('http://localhost:3001/todos'); const todos = await res.json(); + console.log('Expand me:', veryDeepObject); + const dedupedChild = ; const message = getServerState(); return ( diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index bf02c74571..37e7657115 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -1774,6 +1774,40 @@ function applyConstructor( return undefined; } +function defineLazyGetter( + response: Response, + chunk: SomeChunk, + parentObject: Object, + key: string, +): any { + // We don't immediately initialize it even if it's resolved. + // Instead, we wait for the getter to get accessed. + Object.defineProperty(parentObject, key, { + get: function () { + if (chunk.status === RESOLVED_MODEL) { + // If it was now resolved, then we initialize it. This may then discover + // a new set of lazy references that are then asked for eagerly in case + // we get that deep. + initializeModelChunk(chunk); + } + switch (chunk.status) { + case INITIALIZED: { + return chunk.value; + } + case ERRORED: + throw chunk.reason; + } + // Otherwise, we didn't have enough time to load the object before it was + // accessed or the connection closed. So we just log that it was omitted. + // TODO: We should ideally throw here to indicate a difference. + return OMITTED_PROP_ERROR; + }, + enumerable: true, + configurable: false, + }); + return null; +} + function extractIterator(response: Response, model: Array): Iterator { // $FlowFixMe[incompatible-use]: This uses raw Symbols because we're extracting from a native array. return model[Symbol.iterator](); @@ -2014,8 +2048,19 @@ function parseModelString( if (value.length > 2) { const debugChannel = response._debugChannel; if (debugChannel) { - const ref = value.slice(2); - debugChannel('R:' + ref); // Release this reference immediately + const ref = value.slice(2); // We assume this doesn't have a path just id. + const id = parseInt(ref, 16); + if (!response._chunks.has(id)) { + // We haven't seen this id before. Query the server to start sending it. + debugChannel('Q:' + ref); + } + // Start waiting. This now creates a pending chunk if it doesn't already exist. + const chunk = getChunk(response, id); + if (chunk.status === INITIALIZED) { + // We already loaded this before. We can just use the real value. + return chunk.value; + } + return defineLazyGetter(response, chunk, parentObject, key); } } diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index e6e172729f..750fe609ed 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -4820,10 +4820,15 @@ function emitConsoleChunk( const payload = [methodName, stackTrace, owner, env]; // $FlowFixMe[method-unbinding] payload.push.apply(payload, args); - let json = serializeDebugModel(request, 500, payload); + const objectLimit = request.deferredDebugObjects === null ? 500 : 10; + let json = serializeDebugModel( + request, + objectLimit + stackTrace.length, + payload, + ); if (json[0] !== '[') { // This looks like an error. Try a simpler object. - json = serializeDebugModel(request, 500, [ + json = serializeDebugModel(request, 10 + stackTrace.length, [ methodName, stackTrace, owner, @@ -5760,6 +5765,8 @@ export function resolveDebugMessage(request: Request, message: string): void { if (retainedValue !== undefined) { // If we still have this object, and haven't emitted it before, emit it on the stream. const counter = {objectLimit: 10}; + deferredDebugObjects.retained.delete(id); + deferredDebugObjects.existing.delete(retainedValue); emitOutlinedDebugModelChunk(request, id, counter, retainedValue); enqueueFlush(request); }