Skip to content

Commit fa8098e

Browse files
committed
fix: fixed dedupe response cloning
1 parent ac686ff commit fa8098e

File tree

3 files changed

+108
-54
lines changed

3 files changed

+108
-54
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
export function cloneResponse(original: Response): [Response, Response] {
2+
// If the response has no body, then we can just return the original response
3+
// twice because it's immutable.
4+
if (!original.body) {
5+
return [original, original]
6+
}
7+
8+
const [body1, body2] = original.body.tee()
9+
10+
const cloned1 = new Response(body1, {
11+
status: original.status,
12+
statusText: original.statusText,
13+
headers: original.headers,
14+
})
15+
16+
Object.defineProperty(cloned1, 'url', {
17+
value: original.url,
18+
})
19+
20+
const cloned2 = new Response(body2, {
21+
status: original.status,
22+
statusText: original.statusText,
23+
headers: original.headers,
24+
})
25+
26+
Object.defineProperty(cloned2, 'url', {
27+
value: original.url,
28+
})
29+
30+
return [cloned1, cloned2]
31+
}

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

Lines changed: 40 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,34 @@ 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+
const [cloned1, cloned2] = cloneResponse(response)
94+
cacheEntries[i][2] = cloned2
95+
return cloned1
96+
})
9497
}
95-
match = originalFetch(resource, options)
96-
cacheEntries.push(cacheKey, match)
9798
}
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())
99+
100+
// We pass the original arguments here in case normalizing the Request
101+
// doesn't include all the options in this environment.
102+
const promise = originalFetch(resource, options)
103+
const entry: CacheEntry = [cacheKey, promise, null]
104+
cacheEntries.push(entry)
105+
106+
// Attach an empty catch here so we don't get a "unhandled promise
107+
// rejection" warning
108+
promise.catch(() => {})
109+
110+
return promise.then((response) => {
111+
const [cloned1, cloned2] = cloneResponse(response)
112+
entry[2] = cloned2
113+
return cloned1
114+
})
101115
}
102116
}

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

Lines changed: 37 additions & 28 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,21 @@ export function createPatchedFetcher(
676677
statusText: res.statusText,
677678
})
678679
} else {
680+
const [cloned1, cloned2] = cloneResponse(res)
681+
679682
// We are dynamically rendering including dev mode. We want to return
680683
// the response to the caller as soon as possible because it might stream
681684
// over a very long time.
682-
res
683-
.clone()
685+
cloned1
684686
.arrayBuffer()
685687
.then(async (arrayBuffer) => {
686688
const bodyBuffer = Buffer.from(arrayBuffer)
687689

688690
const fetchedData = {
689-
headers: Object.fromEntries(res.headers.entries()),
691+
headers: Object.fromEntries(cloned1.headers.entries()),
690692
body: bodyBuffer.toString('base64'),
691-
status: res.status,
692-
url: res.url,
693+
status: cloned1.status,
694+
url: cloned1.url,
693695
}
694696

695697
requestStore?.serverComponentsHmrCache?.set(
@@ -720,7 +722,7 @@ export function createPatchedFetcher(
720722
)
721723
.finally(handleUnlock)
722724

723-
return res
725+
return cloned2
724726
}
725727
}
726728

@@ -788,14 +790,23 @@ export function createPatchedFetcher(
788790
if (entry.isStale) {
789791
workStore.pendingRevalidates ??= {}
790792
if (!workStore.pendingRevalidates[cacheKey]) {
791-
workStore.pendingRevalidates[cacheKey] = doOriginalFetch(
792-
true
793-
)
794-
.catch(console.error)
793+
const pendingRevalidate = doOriginalFetch(true)
794+
.then(async (response) => ({
795+
body: await response.arrayBuffer(),
796+
headers: response.headers,
797+
status: response.status,
798+
statusText: response.statusText,
799+
}))
795800
.finally(() => {
796801
workStore.pendingRevalidates ??= {}
797802
delete workStore.pendingRevalidates[cacheKey || '']
798803
})
804+
805+
// Attach the empty catch here so we don't get a "unhandled
806+
// promise rejection" warning.
807+
pendingRevalidate.catch(console.error)
808+
809+
workStore.pendingRevalidates[cacheKey] = pendingRevalidate
799810
}
800811
}
801812

@@ -895,7 +906,7 @@ export function createPatchedFetcher(
895906
if (cacheKey && isForegroundRevalidate) {
896907
const pendingRevalidateKey = cacheKey
897908
workStore.pendingRevalidates ??= {}
898-
const pendingRevalidate =
909+
let pendingRevalidate =
899910
workStore.pendingRevalidates[pendingRevalidateKey]
900911

901912
if (pendingRevalidate) {
@@ -920,21 +931,19 @@ export function createPatchedFetcher(
920931
// available we construct manually cloned Response objects with the
921932
// body as an ArrayBuffer. This will be resolvable in a microtask
922933
// making it compatible with dynamicIO.
923-
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-
934+
const pendingResponse = doOriginalFetch(
935+
true,
936+
cacheReasonOverride
937+
).then(cloneResponse)
938+
939+
pendingRevalidate = pendingResponse
940+
.then(async (responses) => {
941+
const response = responses[0]
933942
return {
934-
body: await clonedResponse.arrayBuffer(),
935-
headers: clonedResponse.headers,
936-
status: clonedResponse.status,
937-
statusText: clonedResponse.statusText,
943+
body: await response.arrayBuffer(),
944+
headers: response.headers,
945+
status: response.status,
946+
statusText: response.statusText,
938947
}
939948
})
940949
.finally(() => {
@@ -949,11 +958,11 @@ export function createPatchedFetcher(
949958

950959
// Attach the empty catch here so we don't get a "unhandled promise
951960
// rejection" warning
952-
nextRevalidate.catch(() => {})
961+
pendingRevalidate.catch(() => {})
953962

954-
workStore.pendingRevalidates[pendingRevalidateKey] = nextRevalidate
963+
workStore.pendingRevalidates[pendingRevalidateKey] = pendingRevalidate
955964

956-
return pendingResponse
965+
return pendingResponse.then((responses) => responses[1])
957966
} else {
958967
return doOriginalFetch(false, cacheReasonOverride)
959968
}

0 commit comments

Comments
 (0)