Skip to content

Commit 3855448

Browse files
committed
fix: fixed dedupe response cloning
1 parent 8756f7a commit 3855448

File tree

3 files changed

+131
-55
lines changed

3 files changed

+131
-55
lines changed
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* Clones a response by teeing the body so we can return two independent
3+
* ReadableStreams from it. This avoids the bug in the undici library around
4+
* response cloning.
5+
*
6+
* After cloning, the original response's body will be consumed and closed.
7+
*
8+
* @see https://github.com/vercel/next.js/pull/73274
9+
*
10+
* @param original - The original response to clone.
11+
* @returns A tuple containing two independent clones of the original response.
12+
*/
13+
export function cloneResponse(original: Response): [Response, Response] {
14+
// If the response has no body, then we can just return the original response
15+
// twice because it's immutable.
16+
if (!original.body) {
17+
return [original, original]
18+
}
19+
20+
const [body1, body2] = original.body.tee()
21+
22+
const cloned1 = new Response(body1, {
23+
status: original.status,
24+
statusText: original.statusText,
25+
headers: original.headers,
26+
})
27+
28+
Object.defineProperty(cloned1, 'url', {
29+
value: original.url,
30+
})
31+
32+
const cloned2 = new Response(body2, {
33+
status: original.status,
34+
statusText: original.statusText,
35+
headers: original.headers,
36+
})
37+
38+
Object.defineProperty(cloned2, 'url', {
39+
value: original.url,
40+
})
41+
42+
return [cloned1, cloned2]
43+
}

packages/next/src/server/lib/dedupe-fetch.ts

Lines changed: 44 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
* Based on https://github.com/facebook/react/blob/d4e78c42a94be027b4dc7ed2659a5fddfbf9bd4e/packages/react/src/ReactFetch.js
33
*/
44
import * as React from 'react'
5+
import { cloneResponse } from './clone-response'
6+
import { InvariantError } from '../../shared/lib/invariant-error'
57

68
const simpleCacheKey = '["GET",[],null,"follow",null,null,null,null]' // generateCacheKey(new Request('https://blank'));
79

@@ -24,14 +26,22 @@ function generateCacheKey(request: Request): string {
2426
])
2527
}
2628

29+
type CacheEntry = [
30+
key: string,
31+
promise: Promise<Response>,
32+
response: Response | null,
33+
]
34+
2735
export function createDedupeFetch(originalFetch: typeof fetch) {
28-
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- url is the cache key
29-
const getCacheEntries = React.cache((url: string): Array<any> => [])
36+
const getCacheEntries = React.cache(
37+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- url is the cache key
38+
(url: string): CacheEntry[] => []
39+
)
3040

3141
return function dedupeFetch(
3242
resource: URL | RequestInfo,
3343
options?: RequestInit
34-
) {
44+
): Promise<Response> {
3545
if (options && options.signal) {
3646
// If we're passed a signal, then we assume that
3747
// someone else controls the lifetime of this object and opts out of
@@ -60,7 +70,6 @@ export function createDedupeFetch(originalFetch: typeof fetch) {
6070
: resource
6171
if (
6272
(request.method !== 'GET' && request.method !== 'HEAD') ||
63-
// $FlowFixMe[prop-missing]: keepalive is real
6473
request.keepalive
6574
) {
6675
// We currently don't dedupe requests that might have side-effects. Those
@@ -74,29 +83,38 @@ export function createDedupeFetch(originalFetch: typeof fetch) {
7483
}
7584

7685
const cacheEntries = getCacheEntries(url)
77-
let match
78-
if (cacheEntries.length === 0) {
79-
// We pass the original arguments here in case normalizing the Request
80-
// doesn't include all the options in this environment.
81-
match = originalFetch(resource, options)
82-
cacheEntries.push(cacheKey, match)
83-
} else {
84-
// We use an array as the inner data structure since it's lighter and
85-
// we typically only expect to see one or two entries here.
86-
for (let i = 0, l = cacheEntries.length; i < l; i += 2) {
87-
const key = cacheEntries[i]
88-
const value = cacheEntries[i + 1]
89-
if (key === cacheKey) {
90-
match = value
91-
// I would've preferred a labelled break but lint says no.
92-
return match.then((response: Response) => response.clone())
93-
}
86+
for (let i = 0, j = cacheEntries.length; i < j; i += 1) {
87+
const [key, promise] = cacheEntries[i]
88+
if (key === cacheKey) {
89+
return promise.then(() => {
90+
const response = cacheEntries[i][2]
91+
if (!response) throw new InvariantError('No cached response')
92+
93+
// We're cloning the response using this utility because there exists
94+
// a bug in the undici library around response cloning. See the
95+
// following pull request for more details:
96+
// https://github.com/vercel/next.js/pull/73274
97+
const [cloned1, cloned2] = cloneResponse(response)
98+
cacheEntries[i][2] = cloned2
99+
return cloned1
100+
})
94101
}
95-
match = originalFetch(resource, options)
96-
cacheEntries.push(cacheKey, match)
97102
}
98-
// We clone the response so that each time you call this you get a new read
99-
// of the body so that it can be read multiple times.
100-
return match.then((response) => response.clone())
103+
104+
// We pass the original arguments here in case normalizing the Request
105+
// doesn't include all the options in this environment.
106+
const promise = originalFetch(resource, options)
107+
const entry: CacheEntry = [cacheKey, promise, null]
108+
cacheEntries.push(entry)
109+
110+
return promise.then((response) => {
111+
// We're cloning the response using this utility because there exists
112+
// a bug in the undici library around response cloning. See the
113+
// following pull request for more details:
114+
// https://github.com/vercel/next.js/pull/73274
115+
const [cloned1, cloned2] = cloneResponse(response)
116+
entry[2] = cloned2
117+
return cloned1
118+
})
101119
}
102120
}

packages/next/src/server/lib/patch-fetch.ts

Lines changed: 44 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
type CachedFetchData,
2626
} from '../response-cache'
2727
import { waitAtLeastOneReactRenderTask } from '../../lib/scheduler'
28+
import { cloneResponse } from './clone-response'
2829

2930
const isEdgeRuntime = process.env.NEXT_RUNTIME === 'edge'
3031

@@ -676,20 +677,25 @@ export function createPatchedFetcher(
676677
statusText: res.statusText,
677678
})
678679
} else {
680+
// We're cloning the response using this utility because there
681+
// exists a bug in the undici library around response cloning.
682+
// See the following pull request for more details:
683+
// https://github.com/vercel/next.js/pull/73274
684+
const [cloned1, cloned2] = cloneResponse(res)
685+
679686
// We are dynamically rendering including dev mode. We want to return
680687
// the response to the caller as soon as possible because it might stream
681688
// over a very long time.
682-
res
683-
.clone()
689+
cloned1
684690
.arrayBuffer()
685691
.then(async (arrayBuffer) => {
686692
const bodyBuffer = Buffer.from(arrayBuffer)
687693

688694
const fetchedData = {
689-
headers: Object.fromEntries(res.headers.entries()),
695+
headers: Object.fromEntries(cloned1.headers.entries()),
690696
body: bodyBuffer.toString('base64'),
691-
status: res.status,
692-
url: res.url,
697+
status: cloned1.status,
698+
url: cloned1.url,
693699
}
694700

695701
requestStore?.serverComponentsHmrCache?.set(
@@ -720,7 +726,7 @@ export function createPatchedFetcher(
720726
)
721727
.finally(handleUnlock)
722728

723-
return res
729+
return cloned2
724730
}
725731
}
726732

@@ -788,14 +794,23 @@ export function createPatchedFetcher(
788794
if (entry.isStale) {
789795
workStore.pendingRevalidates ??= {}
790796
if (!workStore.pendingRevalidates[cacheKey]) {
791-
workStore.pendingRevalidates[cacheKey] = doOriginalFetch(
792-
true
793-
)
794-
.catch(console.error)
797+
const pendingRevalidate = doOriginalFetch(true)
798+
.then(async (response) => ({
799+
body: await response.arrayBuffer(),
800+
headers: response.headers,
801+
status: response.status,
802+
statusText: response.statusText,
803+
}))
795804
.finally(() => {
796805
workStore.pendingRevalidates ??= {}
797806
delete workStore.pendingRevalidates[cacheKey || '']
798807
})
808+
809+
// Attach the empty catch here so we don't get a "unhandled
810+
// promise rejection" warning.
811+
pendingRevalidate.catch(console.error)
812+
813+
workStore.pendingRevalidates[cacheKey] = pendingRevalidate
799814
}
800815
}
801816

@@ -895,7 +910,7 @@ export function createPatchedFetcher(
895910
if (cacheKey && isForegroundRevalidate) {
896911
const pendingRevalidateKey = cacheKey
897912
workStore.pendingRevalidates ??= {}
898-
const pendingRevalidate =
913+
let pendingRevalidate =
899914
workStore.pendingRevalidates[pendingRevalidateKey]
900915

901916
if (pendingRevalidate) {
@@ -914,27 +929,27 @@ export function createPatchedFetcher(
914929

915930
// We used to just resolve the Response and clone it however for
916931
// static generation with dynamicIO we need the response to be able to
917-
// be resolved in a microtask and Response#clone() will never have a
918-
// body that can resolve in a microtask in node (as observed through
932+
// be resolved in a microtask and cloning the response will never have
933+
// a body that can resolve in a microtask in node (as observed through
919934
// experimentation) So instead we await the body and then when it is
920935
// available we construct manually cloned Response objects with the
921936
// body as an ArrayBuffer. This will be resolvable in a microtask
922937
// making it compatible with dynamicIO.
923938
const pendingResponse = doOriginalFetch(true, cacheReasonOverride)
924-
925-
const nextRevalidate = pendingResponse
926-
.then(async (response) => {
927-
// Clone the response here. It'll run first because we attached
928-
// the resolve before we returned below. We have to clone it
929-
// because the original response is going to be consumed by
930-
// at a later point in time.
931-
const clonedResponse = response.clone()
932-
939+
// We're cloning the response using this utility because there
940+
// exists a bug in the undici library around response cloning.
941+
// See the following pull request for more details:
942+
// https://github.com/vercel/next.js/pull/73274
943+
.then(cloneResponse)
944+
945+
pendingRevalidate = pendingResponse
946+
.then(async (responses) => {
947+
const response = responses[0]
933948
return {
934-
body: await clonedResponse.arrayBuffer(),
935-
headers: clonedResponse.headers,
936-
status: clonedResponse.status,
937-
statusText: clonedResponse.statusText,
949+
body: await response.arrayBuffer(),
950+
headers: response.headers,
951+
status: response.status,
952+
statusText: response.statusText,
938953
}
939954
})
940955
.finally(() => {
@@ -949,11 +964,11 @@ export function createPatchedFetcher(
949964

950965
// Attach the empty catch here so we don't get a "unhandled promise
951966
// rejection" warning
952-
nextRevalidate.catch(() => {})
967+
pendingRevalidate.catch(() => {})
953968

954-
workStore.pendingRevalidates[pendingRevalidateKey] = nextRevalidate
969+
workStore.pendingRevalidates[pendingRevalidateKey] = pendingRevalidate
955970

956-
return pendingResponse
971+
return pendingResponse.then((responses) => responses[1])
957972
} else {
958973
return doOriginalFetch(false, cacheReasonOverride)
959974
}

0 commit comments

Comments
 (0)