@samitouri / QOS-React-1 / commits / bbea677b77

[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.

Sebastian Markbåge committed Jul 8, 2025 at 10:49 UTC bbea677b77ebf5d696623e2f634c69744eaf9d86
3 files changed +115 -4
fixtures/flight/src/App.js
+59
@@ -120,10 +120,69 @@ async function ServerComponent({noCache}) {
120 return await fetchThirdParty(noCache);
121 }
122
123 +let veryDeepObject = [
124 + {
125 + bar: {
126 + baz: {
127 + a: {},
128 + },
129 + },
130 + },
131 + {
132 + bar: {
133 + baz: {
134 + a: {},
135 + },
136 + },
137 + },
138 + {
139 + bar: {
140 + baz: {
141 + a: {},
142 + },
143 + },
144 + },
145 + {
146 + bar: {
147 + baz: {
148 + a: {
149 + b: {
150 + c: {
151 + d: {
152 + e: {
153 + f: {
154 + g: {
155 + h: {
156 + i: {
157 + j: {
158 + k: {
159 + l: {
160 + m: {
161 + yay: 'You reached the end',
162 + },
163 + },
164 + },
165 + },
166 + },
167 + },
168 + },
169 + },
170 + },
171 + },
172 + },
173 + },
174 + },
175 + },
176 + },
177 + },
178 +];
179 +
180 export default async function App({prerender, noCache}) {
181 const res = await fetch('http://localhost:3001/todos');
182 const todos = await res.json();
183
184 + console.log('Expand me:', veryDeepObject);
185 +
186 const dedupedChild = <ServerComponent noCache={noCache} />;
187 const message = getServerState();
188 return (
packages/react-client/src/ReactFlightClient.js
+47 -2
@@ -1774,6 +1774,40 @@ function applyConstructor(
1774 return undefined;
1775 }
1776
1777 +function defineLazyGetter<T>(
1778 + response: Response,
1779 + chunk: SomeChunk<T>,
1780 + parentObject: Object,
1781 + key: string,
1782 +): any {
1783 + // We don't immediately initialize it even if it's resolved.
1784 + // Instead, we wait for the getter to get accessed.
1785 + Object.defineProperty(parentObject, key, {
1786 + get: function () {
1787 + if (chunk.status === RESOLVED_MODEL) {
1788 + // If it was now resolved, then we initialize it. This may then discover
1789 + // a new set of lazy references that are then asked for eagerly in case
1790 + // we get that deep.
1791 + initializeModelChunk(chunk);
1792 + }
1793 + switch (chunk.status) {
1794 + case INITIALIZED: {
1795 + return chunk.value;
1796 + }
1797 + case ERRORED:
1798 + throw chunk.reason;
1799 + }
1800 + // Otherwise, we didn't have enough time to load the object before it was
1801 + // accessed or the connection closed. So we just log that it was omitted.
1802 + // TODO: We should ideally throw here to indicate a difference.
1803 + return OMITTED_PROP_ERROR;
1804 + },
1805 + enumerable: true,
1806 + configurable: false,
1807 + });
1808 + return null;
1809 +}
1810 +
1811 function extractIterator(response: Response, model: Array<any>): Iterator<any> {
1812 // $FlowFixMe[incompatible-use]: This uses raw Symbols because we're extracting from a native array.
1813 return model[Symbol.iterator]();
@@ -2014,8 +2048,19 @@ function parseModelString(
2048 if (value.length > 2) {
2049 const debugChannel = response._debugChannel;
2050 if (debugChannel) {
2017 - const ref = value.slice(2);
2018 - debugChannel('R:' + ref); // Release this reference immediately
2051 + const ref = value.slice(2); // We assume this doesn't have a path just id.
2052 + const id = parseInt(ref, 16);
2053 + if (!response._chunks.has(id)) {
2054 + // We haven't seen this id before. Query the server to start sending it.
2055 + debugChannel('Q:' + ref);
2056 + }
2057 + // Start waiting. This now creates a pending chunk if it doesn't already exist.
2058 + const chunk = getChunk(response, id);
2059 + if (chunk.status === INITIALIZED) {
2060 + // We already loaded this before. We can just use the real value.
2061 + return chunk.value;
2062 + }
2063 + return defineLazyGetter(response, chunk, parentObject, key);
2064 }
2065 }
2066
packages/react-server/src/ReactFlightServer.js
+9 -2
@@ -4820,10 +4820,15 @@ function emitConsoleChunk(
4820 const payload = [methodName, stackTrace, owner, env];
4821 // $FlowFixMe[method-unbinding]
4822 payload.push.apply(payload, args);
4823 - let json = serializeDebugModel(request, 500, payload);
4823 + const objectLimit = request.deferredDebugObjects === null ? 500 : 10;
4824 + let json = serializeDebugModel(
4825 + request,
4826 + objectLimit + stackTrace.length,
4827 + payload,
4828 + );
4829 if (json[0] !== '[') {
4830 // This looks like an error. Try a simpler object.
4826 - json = serializeDebugModel(request, 500, [
4831 + json = serializeDebugModel(request, 10 + stackTrace.length, [
4832 methodName,
4833 stackTrace,
4834 owner,
@@ -5760,6 +5765,8 @@ export function resolveDebugMessage(request: Request, message: string): void {
5765 if (retainedValue !== undefined) {
5766 // If we still have this object, and haven't emitted it before, emit it on the stream.
5767 const counter = {objectLimit: 10};
5768 + deferredDebugObjects.retained.delete(id);
5769 + deferredDebugObjects.existing.delete(retainedValue);
5770 emitOutlinedDebugModelChunk(request, id, counter, retainedValue);
5771 enqueueFlush(request);
5772 }