Skip to content

Commit e7fa1a9

Browse files
committed
Include server latency in navigation debug info
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.
1 parent cc01bcd commit e7fa1a9

File tree

3 files changed

+106
-36
lines changed

3 files changed

+106
-36
lines changed

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

Lines changed: 78 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,
@@ -36,6 +39,8 @@ import { urlToUrlWithoutFlightMarker } from '../../route-params'
3639

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

4045
let createDebugChannel:
4146
| typeof import('../../dev/debug-channel').createDebugChannel
@@ -170,10 +175,17 @@ export async function fetchServerResponse(
170175
}
171176
}
172177

173-
const res = await createFetch(
178+
// Typically, during a navigation, we decode the response using Flight's
179+
// `createFromFetch` API, which accepts a `fetch` promise.
180+
// TODO: Remove this check once the old PPR flag is removed
181+
const isLegacyPPR =
182+
process.env.__NEXT_PPR && !process.env.__NEXT_CACHE_COMPONENTS
183+
const shouldImmediatelyDecode = !isLegacyPPR
184+
const res = await createFetch<NavigationFlightResponse>(
174185
url,
175186
headers,
176187
fetchPriority,
188+
shouldImmediatelyDecode,
177189
abortController.signal
178190
)
179191

@@ -221,24 +233,34 @@ export async function fetchServerResponse(
221233
).waitForWebpackRuntimeHotUpdate()
222234
}
223235

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

233-
if (getAppBuildId() !== response.b) {
255+
if (getAppBuildId() !== flightResponse.b) {
234256
return doMpaNavigation(res.url)
235257
}
236258

237259
return {
238-
flightData: normalizeFlightData(response.f),
260+
flightData: normalizeFlightData(flightResponse.f),
239261
canonicalUrl: canonicalUrl,
240262
couldBeIntercepted: interception,
241-
prerendered: response.S,
263+
prerendered: flightResponse.S,
242264
postponed,
243265
staleTime,
244266
}
@@ -269,21 +291,23 @@ export async function fetchServerResponse(
269291
// the codebase. For example, there's some custom logic for manually following
270292
// redirects, so "redirected" in this type could be a composite of multiple
271293
// browser fetch calls; however, this fact should not leak to the caller.
272-
export type RSCResponse = {
294+
export type RSCResponse<T> = {
273295
ok: boolean
274296
redirected: boolean
275297
headers: Headers
276298
body: ReadableStream<Uint8Array> | null
277299
status: number
278300
url: string
301+
flightResponse: (Promise<T> & { _debugInfo?: Array<any> }) | null
279302
}
280303

281-
export async function createFetch(
304+
export async function createFetch<T>(
282305
url: URL,
283306
headers: RequestHeaders,
284307
fetchPriority: 'auto' | 'high' | 'low' | null,
308+
shouldImmediatelyDecode: boolean,
285309
signal?: AbortSignal
286-
): Promise<RSCResponse> {
310+
): Promise<RSCResponse<T>> {
287311
// TODO: In output: "export" mode, the headers do nothing. Omit them (and the
288312
// cache busting search param) from the request so they're
289313
// maximally cacheable.
@@ -312,7 +336,21 @@ export async function createFetch(
312336
// track them separately.
313337
let fetchUrl = new URL(url)
314338
setCacheBustingSearchParam(fetchUrl, headers)
315-
let browserResponse = await fetch(fetchUrl, fetchOptions)
339+
let fetchPromise = fetch(fetchUrl, fetchOptions)
340+
// Immediately pass the fetch promise to the Flight client so that the debug
341+
// info includes the latency from the client to the server. The internal timer
342+
// in React starts as soon as `createFromFetch` is called.
343+
//
344+
// The only case where we don't do this is during a prefetch, because we have
345+
// to do some extra processing of the response stream (see
346+
// `createUnclosingPrefetchStream`). But this is fine, because a top-level
347+
// prefetch response never blocks a navigation; if it hasn't already been
348+
// written into the cache by the time the navigation happens, the router will
349+
// go straight to a dynamic request.
350+
let flightResponsePromise = shouldImmediatelyDecode
351+
? createFromNextFetch<T>(fetchPromise)
352+
: null
353+
let browserResponse = await fetchPromise
316354

317355
// If the server responds with a redirect (e.g. 307), and the redirected
318356
// location does not contain the cache busting search param set in the
@@ -365,9 +403,14 @@ export async function createFetch(
365403
//
366404
// Append the cache busting search param to the redirected URL and
367405
// fetch again.
406+
// TODO: We should abort the previous request.
368407
fetchUrl = new URL(responseUrl)
369408
setCacheBustingSearchParam(fetchUrl, headers)
370-
browserResponse = await fetch(fetchUrl, fetchOptions)
409+
fetchPromise = fetch(fetchUrl, fetchOptions)
410+
flightResponsePromise = shouldImmediatelyDecode
411+
? createFromNextFetch<T>(fetchPromise)
412+
: null
413+
browserResponse = await fetchPromise
371414
// We just performed a manual redirect, so this is now true.
372415
redirected = true
373416
}
@@ -378,7 +421,7 @@ export async function createFetch(
378421
const responseUrl = new URL(browserResponse.url, fetchUrl)
379422
responseUrl.searchParams.delete(NEXT_RSC_UNION_QUERY)
380423

381-
const rscResponse: RSCResponse = {
424+
const rscResponse: RSCResponse<T> = {
382425
url: responseUrl.href,
383426

384427
// This is true if any redirects occurred, either automatically by the
@@ -394,22 +437,36 @@ export async function createFetch(
394437
headers: browserResponse.headers,
395438
body: browserResponse.body,
396439
status: browserResponse.status,
440+
441+
// This is the exact promise returned by `createFromFetch`. It contains
442+
// debug information that we need to transfer to any derived promises that
443+
// are later rendered by React.
444+
flightResponse: flightResponsePromise,
397445
}
398446

399447
return rscResponse
400448
}
401449

402-
export function createFromNextReadableStream(
450+
export function createFromNextReadableStream<T>(
403451
flightStream: ReadableStream<Uint8Array>,
404452
responseHeaders: Headers
405-
): Promise<unknown> {
453+
): Promise<T> & { _debugInfo?: Array<any> } {
406454
return createFromReadableStream(flightStream, {
407455
callServer,
408456
findSourceMapURL,
409457
debugChannel: createDebugChannel && createDebugChannel(responseHeaders),
410458
})
411459
}
412460

461+
function createFromNextFetch<T>(
462+
promiseForResponse: Promise<Response>
463+
): Promise<T> & { _debugInfo?: Array<any> } {
464+
return createFromFetch(promiseForResponse, {
465+
callServer,
466+
findSourceMapURL,
467+
})
468+
}
469+
413470
function createUnclosingPrefetchStream(
414471
originalFlightStream: ReadableStream<Uint8Array>
415472
): ReadableStream<Uint8Array> {

packages/next/src/client/components/segment-cache-impl/cache.ts

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1430,10 +1430,10 @@ export async function fetchRouteOnCacheMiss(
14301430
routeCacheLru.updateSize(entry, size)
14311431
}
14321432
)
1433-
const serverData = await (createFromNextReadableStream(
1433+
const serverData = await createFromNextReadableStream<RootTreePrefetch>(
14341434
prefetchStream,
14351435
response.headers
1436-
) as Promise<RootTreePrefetch>)
1436+
)
14371437
if (serverData.buildId !== getAppBuildId()) {
14381438
// The server build does not match the client. Treat as a 404. During
14391439
// an actual navigation, the router will trigger an MPA navigation.
@@ -1482,10 +1482,11 @@ export async function fetchRouteOnCacheMiss(
14821482
routeCacheLru.updateSize(entry, size)
14831483
}
14841484
)
1485-
const serverData = await (createFromNextReadableStream(
1486-
prefetchStream,
1487-
response.headers
1488-
) as Promise<NavigationFlightResponse>)
1485+
const serverData =
1486+
await createFromNextReadableStream<NavigationFlightResponse>(
1487+
prefetchStream,
1488+
response.headers
1489+
)
14891490
if (serverData.b !== getAppBuildId()) {
14901491
// The server build does not match the client. Treat as a 404. During
14911492
// an actual navigation, the router will trigger an MPA navigation.
@@ -1503,7 +1504,7 @@ export async function fetchRouteOnCacheMiss(
15031504
// The non-PPR response format is what we'd get if we prefetched these segments
15041505
// using the LoadingBoundary fetch strategy, so mark their cache entries accordingly.
15051506
FetchStrategy.LoadingBoundary,
1506-
response,
1507+
response as RSCResponse<NavigationFlightResponse>,
15071508
serverData,
15081509
entry,
15091510
couldBeIntercepted,
@@ -1768,7 +1769,7 @@ export async function fetchSegmentPrefetchesUsingDynamicRequest(
17681769
Date.now(),
17691770
task,
17701771
fetchStrategy,
1771-
response,
1772+
response as RSCResponse<NavigationFlightResponse>,
17721773
serverData,
17731774
isResponsePartial,
17741775
route,
@@ -1791,7 +1792,7 @@ function writeDynamicTreeResponseIntoCache(
17911792
| FetchStrategy.LoadingBoundary
17921793
| FetchStrategy.PPRRuntime
17931794
| FetchStrategy.Full,
1794-
response: RSCResponse,
1795+
response: RSCResponse<NavigationFlightResponse>,
17951796
serverData: NavigationFlightResponse,
17961797
entry: PendingRouteCacheEntry,
17971798
couldBeIntercepted: boolean,
@@ -1896,7 +1897,7 @@ function writeDynamicRenderResponseIntoCache(
18961897
| FetchStrategy.LoadingBoundary
18971898
| FetchStrategy.PPRRuntime
18981899
| FetchStrategy.Full,
1899-
response: RSCResponse,
1900+
response: RSCResponse<NavigationFlightResponse>,
19001901
serverData: NavigationFlightResponse,
19011902
isResponsePartial: boolean,
19021903
route: FulfilledRouteCacheEntry,
@@ -2120,12 +2121,22 @@ function writeSeedDataIntoCache(
21202121
}
21212122
}
21222123

2123-
async function fetchPrefetchResponse(
2124+
async function fetchPrefetchResponse<T>(
21242125
url: URL,
21252126
headers: RequestHeaders
2126-
): Promise<RSCResponse | null> {
2127+
): Promise<RSCResponse<T> | null> {
21272128
const fetchPriority = 'low'
2128-
const response = await createFetch(url, headers, fetchPriority)
2129+
// When issuing a prefetch request, don't immediately decode the response; we
2130+
// use the lower level `createFromResponse` API instead because we need to do
2131+
// some extra processing of the response stream. See
2132+
// `createPrefetchResponseStream` for more details.
2133+
const shouldImmediatelyDecode = false
2134+
const response = await createFetch<T>(
2135+
url,
2136+
headers,
2137+
fetchPriority,
2138+
shouldImmediatelyDecode
2139+
)
21292140
if (!response.ok) {
21302141
return null
21312142
}

packages/next/src/client/route-params.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ export type RouteParam = {
2121
type: DynamicParamTypesShort
2222
}
2323

24-
export function getRenderedSearch(response: RSCResponse): NormalizedSearch {
24+
export function getRenderedSearch(
25+
response: RSCResponse<unknown>
26+
): NormalizedSearch {
2527
// If the server performed a rewrite, the search params used to render the
2628
// page will be different from the params in the request URL. In this case,
2729
// the response will include a header that gives the rewritten search query.
@@ -37,7 +39,7 @@ export function getRenderedSearch(response: RSCResponse): NormalizedSearch {
3739
.search as NormalizedSearch
3840
}
3941

40-
export function getRenderedPathname(response: RSCResponse): string {
42+
export function getRenderedPathname(response: RSCResponse<unknown>): string {
4143
// If the server performed a rewrite, the pathname used to render the
4244
// page will be different from the pathname in the request URL. In this case,
4345
// the response will include a header that gives the rewritten pathname.

0 commit comments

Comments
 (0)