Skip to content

Commit 2e32756

Browse files
authored
Include server latency in debug info (#84580)
The debug info of the promise created by the Flight client represents the latency between when the navigation starts and when it's passed to React. Most importantly, it should include the time it takes for the client to start receiving data from the server. Before this PR, the timing was too late because we did not call into the Flight client until after we started receiving data. To fix this, we switch from using `createFromReadableStream` to `createFromFetch`. This allows us to call into the Flight client immediately without waiting for the `fetch` promise to resolve. The `_debugInfo` field contains the profiling information. Promises that are created by Flight already have this info added by React; for any derived promise created by the router, we need to transfer the Flight debug info onto the derived promise. Concretely, we create a derived promise for each route segment contained in a response. Any nested promises contained with that segment are passed directly from Flight to React and don't require special processing.
1 parent 3247607 commit 2e32756

File tree

13 files changed

+243
-78
lines changed

13 files changed

+243
-78
lines changed

packages/next/src/client/components/router-reducer/create-initial-router-state.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ describe('createInitialRouterState', () => {
139139
cache: expectedCache,
140140
nextUrl: '/linking',
141141
previousNextUrl: null,
142+
debugInfo: null,
142143
}
143144

144145
expect(state).toMatchObject(expected)

packages/next/src/client/components/router-reducer/create-initial-router-state.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ export function createInitialRouterState({
111111
(extractPathFromFlightRouterState(initialTree) || location?.pathname) ??
112112
null,
113113
previousNextUrl: null,
114+
debugInfo: null,
114115
}
115116

116117
if (process.env.NODE_ENV !== 'development' && location) {
@@ -146,6 +147,7 @@ export function createInitialRouterState({
146147
prerendered && !process.env.__NEXT_CLIENT_SEGMENT_CACHE
147148
? STATIC_STALETIME_MS
148149
: -1,
150+
debugInfo: null,
149151
},
150152
tree: initialState.tree,
151153
prefetchCache: initialState.prefetchCache,

packages/next/src/client/components/router-reducer/fetch-server-response.ts

Lines changed: 84 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
// TODO: Explicitly import from client.browser
44
// eslint-disable-next-line import/no-extraneous-dependencies
5-
import { createFromReadableStream as createFromReadableStreamBrowser } from 'react-server-dom-webpack/client'
5+
import {
6+
createFromReadableStream as createFromReadableStreamBrowser,
7+
createFromFetch as createFromFetchBrowser,
8+
} from 'react-server-dom-webpack/client'
69

710
import type {
811
FlightRouterState,
@@ -37,6 +40,8 @@ import { urlToUrlWithoutFlightMarker } from '../../route-params'
3740

3841
const createFromReadableStream =
3942
createFromReadableStreamBrowser as (typeof import('react-server-dom-webpack/client.browser'))['createFromReadableStream']
43+
const createFromFetch =
44+
createFromFetchBrowser as (typeof import('react-server-dom-webpack/client.browser'))['createFromFetch']
4045

4146
let createDebugChannel:
4247
| typeof import('../../dev/debug-channel').createDebugChannel
@@ -65,6 +70,7 @@ export type FetchServerResponseResult = {
6570
prerendered: boolean
6671
postponed: boolean
6772
staleTime: number
73+
debugInfo: Array<any> | null
6874
}
6975

7076
export type RequestHeaders = {
@@ -91,6 +97,7 @@ function doMpaNavigation(url: string): FetchServerResponseResult {
9197
prerendered: false,
9298
postponed: false,
9399
staleTime: -1,
100+
debugInfo: null,
94101
}
95102
}
96103

@@ -175,10 +182,17 @@ export async function fetchServerResponse(
175182
}
176183
}
177184

178-
const res = await createFetch(
185+
// Typically, during a navigation, we decode the response using Flight's
186+
// `createFromFetch` API, which accepts a `fetch` promise.
187+
// TODO: Remove this check once the old PPR flag is removed
188+
const isLegacyPPR =
189+
process.env.__NEXT_PPR && !process.env.__NEXT_CACHE_COMPONENTS
190+
const shouldImmediatelyDecode = !isLegacyPPR
191+
const res = await createFetch<NavigationFlightResponse>(
179192
url,
180193
headers,
181194
fetchPriority,
195+
shouldImmediatelyDecode,
182196
abortController.signal
183197
)
184198

@@ -226,26 +240,37 @@ export async function fetchServerResponse(
226240
).waitForWebpackRuntimeHotUpdate()
227241
}
228242

229-
// Handle the `fetch` readable stream that can be unwrapped by `React.use`.
230-
const flightStream = postponed
231-
? createUnclosingPrefetchStream(res.body)
232-
: res.body
233-
const response = await (createFromNextReadableStream(
234-
flightStream,
235-
headers
236-
) as Promise<NavigationFlightResponse>)
243+
let flightResponsePromise = res.flightResponse
244+
if (flightResponsePromise === null) {
245+
// Typically, `createFetch` would have already started decoding the
246+
// Flight response. If it hasn't, though, we need to decode it now.
247+
// TODO: This should only be reachable if legacy PPR is enabled (i.e. PPR
248+
// without Cache Components). Remove this branch once legacy PPR
249+
// is deleted.
250+
const flightStream = postponed
251+
? createUnclosingPrefetchStream(res.body)
252+
: res.body
253+
flightResponsePromise =
254+
createFromNextReadableStream<NavigationFlightResponse>(
255+
flightStream,
256+
headers
257+
)
258+
}
259+
260+
const flightResponse = await flightResponsePromise
237261

238-
if (getAppBuildId() !== response.b) {
262+
if (getAppBuildId() !== flightResponse.b) {
239263
return doMpaNavigation(res.url)
240264
}
241265

242266
return {
243-
flightData: normalizeFlightData(response.f),
267+
flightData: normalizeFlightData(flightResponse.f),
244268
canonicalUrl: canonicalUrl,
245269
couldBeIntercepted: interception,
246-
prerendered: response.S,
270+
prerendered: flightResponse.S,
247271
postponed,
248272
staleTime,
273+
debugInfo: flightResponsePromise._debugInfo ?? null,
249274
}
250275
} catch (err) {
251276
if (!abortController.signal.aborted) {
@@ -265,6 +290,7 @@ export async function fetchServerResponse(
265290
prerendered: false,
266291
postponed: false,
267292
staleTime: -1,
293+
debugInfo: null,
268294
}
269295
}
270296
}
@@ -274,21 +300,23 @@ export async function fetchServerResponse(
274300
// the codebase. For example, there's some custom logic for manually following
275301
// redirects, so "redirected" in this type could be a composite of multiple
276302
// browser fetch calls; however, this fact should not leak to the caller.
277-
export type RSCResponse = {
303+
export type RSCResponse<T> = {
278304
ok: boolean
279305
redirected: boolean
280306
headers: Headers
281307
body: ReadableStream<Uint8Array> | null
282308
status: number
283309
url: string
310+
flightResponse: (Promise<T> & { _debugInfo?: Array<any> }) | null
284311
}
285312

286-
export async function createFetch(
313+
export async function createFetch<T>(
287314
url: URL,
288315
headers: RequestHeaders,
289316
fetchPriority: 'auto' | 'high' | 'low' | null,
317+
shouldImmediatelyDecode: boolean,
290318
signal?: AbortSignal
291-
): Promise<RSCResponse> {
319+
): Promise<RSCResponse<T>> {
292320
// TODO: In output: "export" mode, the headers do nothing. Omit them (and the
293321
// cache busting search param) from the request so they're
294322
// maximally cacheable.
@@ -326,7 +354,21 @@ export async function createFetch(
326354
// track them separately.
327355
let fetchUrl = new URL(url)
328356
setCacheBustingSearchParam(fetchUrl, headers)
329-
let browserResponse = await fetch(fetchUrl, fetchOptions)
357+
let fetchPromise = fetch(fetchUrl, fetchOptions)
358+
// Immediately pass the fetch promise to the Flight client so that the debug
359+
// info includes the latency from the client to the server. The internal timer
360+
// in React starts as soon as `createFromFetch` is called.
361+
//
362+
// The only case where we don't do this is during a prefetch, because we have
363+
// to do some extra processing of the response stream (see
364+
// `createUnclosingPrefetchStream`). But this is fine, because a top-level
365+
// prefetch response never blocks a navigation; if it hasn't already been
366+
// written into the cache by the time the navigation happens, the router will
367+
// go straight to a dynamic request.
368+
let flightResponsePromise = shouldImmediatelyDecode
369+
? createFromNextFetch<T>(fetchPromise, headers)
370+
: null
371+
let browserResponse = await fetchPromise
330372

331373
// If the server responds with a redirect (e.g. 307), and the redirected
332374
// location does not contain the cache busting search param set in the
@@ -379,9 +421,14 @@ export async function createFetch(
379421
//
380422
// Append the cache busting search param to the redirected URL and
381423
// fetch again.
424+
// TODO: We should abort the previous request.
382425
fetchUrl = new URL(responseUrl)
383426
setCacheBustingSearchParam(fetchUrl, headers)
384-
browserResponse = await fetch(fetchUrl, fetchOptions)
427+
fetchPromise = fetch(fetchUrl, fetchOptions)
428+
flightResponsePromise = shouldImmediatelyDecode
429+
? createFromNextFetch<T>(fetchPromise, headers)
430+
: null
431+
browserResponse = await fetchPromise
385432
// We just performed a manual redirect, so this is now true.
386433
redirected = true
387434
}
@@ -392,7 +439,7 @@ export async function createFetch(
392439
const responseUrl = new URL(browserResponse.url, fetchUrl)
393440
responseUrl.searchParams.delete(NEXT_RSC_UNION_QUERY)
394441

395-
const rscResponse: RSCResponse = {
442+
const rscResponse: RSCResponse<T> = {
396443
url: responseUrl.href,
397444

398445
// This is true if any redirects occurred, either automatically by the
@@ -408,22 +455,38 @@ export async function createFetch(
408455
headers: browserResponse.headers,
409456
body: browserResponse.body,
410457
status: browserResponse.status,
458+
459+
// This is the exact promise returned by `createFromFetch`. It contains
460+
// debug information that we need to transfer to any derived promises that
461+
// are later rendered by React.
462+
flightResponse: flightResponsePromise,
411463
}
412464

413465
return rscResponse
414466
}
415467

416-
export function createFromNextReadableStream(
468+
export function createFromNextReadableStream<T>(
417469
flightStream: ReadableStream<Uint8Array>,
418470
requestHeaders: RequestHeaders
419-
): Promise<unknown> {
471+
): Promise<T> {
420472
return createFromReadableStream(flightStream, {
421473
callServer,
422474
findSourceMapURL,
423475
debugChannel: createDebugChannel && createDebugChannel(requestHeaders),
424476
})
425477
}
426478

479+
function createFromNextFetch<T>(
480+
promiseForResponse: Promise<Response>,
481+
requestHeaders: RequestHeaders
482+
): Promise<T> & { _debugInfo?: Array<any> } {
483+
return createFromFetch(promiseForResponse, {
484+
callServer,
485+
findSourceMapURL,
486+
debugChannel: createDebugChannel && createDebugChannel(requestHeaders),
487+
})
488+
}
489+
427490
function createUnclosingPrefetchStream(
428491
originalFlightStream: ReadableStream<Uint8Array>
429492
): ReadableStream<Uint8Array> {

packages/next/src/client/components/router-reducer/handle-mutable.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,5 +87,6 @@ export function handleMutable(
8787
: state.tree,
8888
nextUrl,
8989
previousNextUrl: previousNextUrl,
90+
debugInfo: mutable.collectedDebugInfo ?? null,
9091
}
9192
}

0 commit comments

Comments
 (0)