Skip to content

Commit 15aeb92

Browse files
authored
misc: tweak fetch patch restoration timing during HMR to allow for userland fetch patching (#68193)
1 parent bfa7df4 commit 15aeb92

File tree

11 files changed

+158
-30
lines changed

11 files changed

+158
-30
lines changed

packages/next/src/server/dev/hot-reloader-turbopack.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,8 @@ const sessionId = Math.floor(Number.MAX_SAFE_INTEGER * Math.random())
9292
export async function createHotReloaderTurbopack(
9393
opts: SetupOpts,
9494
serverFields: ServerFields,
95-
distDir: string
95+
distDir: string,
96+
resetFetch: () => void
9697
): Promise<NextJsHotReloaderInterface> {
9798
const buildId = 'development'
9899
const { nextConfig, dir } = opts
@@ -236,6 +237,8 @@ export async function createHotReloaderTurbopack(
236237
}
237238
}
238239

240+
resetFetch()
241+
239242
const hasAppPaths = writtenEndpoint.serverPaths.some(({ path: p }) =>
240243
p.startsWith('server/app')
241244
)

packages/next/src/server/dev/hot-reloader-webpack.ts

+5
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface {
249249
private pagesMapping: { [key: string]: string } = {}
250250
private appDir?: string
251251
private telemetry: Telemetry
252+
private resetFetch: () => void
252253
private versionInfo: VersionInfo = {
253254
staleness: 'unknown',
254255
installed: '0.0.0',
@@ -274,6 +275,7 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface {
274275
rewrites,
275276
appDir,
276277
telemetry,
278+
resetFetch,
277279
}: {
278280
config: NextConfigComplete
279281
pagesDir?: string
@@ -284,6 +286,7 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface {
284286
rewrites: CustomRoutes['rewrites']
285287
appDir?: string
286288
telemetry: Telemetry
289+
resetFetch: () => void
287290
}
288291
) {
289292
this.hasAmpEntrypoints = false
@@ -301,6 +304,7 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface {
301304
this.edgeServerStats = null
302305
this.serverPrevDocumentHash = null
303306
this.telemetry = telemetry
307+
this.resetFetch = resetFetch
304308

305309
this.config = config
306310
this.previewProps = previewProps
@@ -1365,6 +1369,7 @@ export default class HotReloaderWebpack implements NextJsHotReloaderInterface {
13651369
changedCSSImportPages.size ||
13661370
reloadAfterInvalidation
13671371
) {
1372+
this.resetFetch()
13681373
this.refreshServerComponents()
13691374
}
13701375

packages/next/src/server/dev/next-dev-server.ts

-17
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,6 @@ export default class DevServer extends Server {
162162
this.bundlerService = options.bundlerService
163163
this.startServerSpan =
164164
options.startServerSpan ?? trace('start-next-dev-server')
165-
this.storeGlobals()
166165
this.renderOpts.dev = true
167166
this.renderOpts.ErrorDebug = ReactDevOverlay
168167
this.staticPathsCache = new LRUCache({
@@ -294,9 +293,6 @@ export default class DevServer extends Server {
294293
await super.prepareImpl()
295294
await this.matchers.reload()
296295

297-
// Store globals again to preserve changes made by the instrumentation hook.
298-
this.storeGlobals()
299-
300296
this.ready?.resolve()
301297
this.ready = undefined
302298

@@ -825,14 +821,6 @@ export default class DevServer extends Server {
825821
return nextInvoke as NonNullable<typeof result>
826822
}
827823

828-
private storeGlobals(): void {
829-
this.originalFetch = global.fetch
830-
}
831-
832-
private restorePatchedGlobals(): void {
833-
global.fetch = this.originalFetch ?? global.fetch
834-
}
835-
836824
protected async ensurePage(opts: {
837825
page: string
838826
clientOnly: boolean
@@ -880,11 +868,6 @@ export default class DevServer extends Server {
880868
}
881869

882870
this.nextFontManifest = super.getNextFontManifest()
883-
// before we re-evaluate a route module, we want to restore globals that might
884-
// have been patched previously to their original state so that we don't
885-
// patch on top of the previous patch, which would keep the context of the previous
886-
// patched global in memory, creating a memory leak.
887-
this.restorePatchedGlobals()
888871

889872
return await super.findPageComponents({
890873
page,

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

+9-6
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,10 @@ type PatchedFetcher = Fetcher & {
3434
readonly _nextOriginalFetch: Fetcher
3535
}
3636

37-
function isPatchedFetch(
38-
fetch: Fetcher | PatchedFetcher
39-
): fetch is PatchedFetcher {
40-
return '__nextPatched' in fetch && fetch.__nextPatched === true
37+
export const NEXT_PATCH_SYMBOL = Symbol.for('next-patch')
38+
39+
function isFetchPatched() {
40+
return (globalThis as Record<symbol, unknown>)[NEXT_PATCH_SYMBOL] === true
4141
}
4242

4343
export function validateRevalidate(
@@ -804,18 +804,21 @@ export function createPatchedFetcher(
804804
}
805805

806806
// Attach the necessary properties to the patched fetch function.
807+
// We don't use this to determine if the fetch function has been patched,
808+
// but for external consumers to determine if the fetch function has been
809+
// patched.
807810
patched.__nextPatched = true as const
808811
patched.__nextGetStaticStore = () => staticGenerationAsyncStorage
809812
patched._nextOriginalFetch = originFetch
813+
;(globalThis as Record<symbol, unknown>)[NEXT_PATCH_SYMBOL] = true
810814

811815
return patched
812816
}
813-
814817
// we patch fetch to collect cache information used for
815818
// determining if a page is static or not
816819
export function patchFetch(options: PatchableModule) {
817820
// If we've already patched fetch, we should not patch it again.
818-
if (isPatchedFetch(globalThis.fetch)) return
821+
if (isFetchPatched()) return
819822

820823
// Grab the original fetch function. We'll attach this so we can use it in
821824
// the patched fetch function.

packages/next/src/server/lib/router-server.ts

+13-4
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
type AppIsrManifestAction,
4747
} from '../dev/hot-reloader-types'
4848
import { normalizedAssetPrefix } from '../../shared/lib/normalized-asset-prefix'
49+
import { NEXT_PATCH_SYMBOL } from './patch-fetch'
4950

5051
const debug = setupDebug('next:router-server:main')
5152
const isNextFont = (pathname: string | null) =>
@@ -109,6 +110,8 @@ export async function initialize(opts: {
109110

110111
let devBundlerService: DevBundlerService | undefined
111112

113+
let originalFetch = globalThis.fetch
114+
112115
if (opts.dev) {
113116
const { Telemetry } =
114117
require('../../telemetry/storage') as typeof import('../../telemetry/storage')
@@ -121,6 +124,11 @@ export async function initialize(opts: {
121124
const { setupDevBundler } =
122125
require('./router-utils/setup-dev-bundler') as typeof import('./router-utils/setup-dev-bundler')
123126

127+
const resetFetch = () => {
128+
globalThis.fetch = originalFetch
129+
;(globalThis as Record<symbol, unknown>)[NEXT_PATCH_SYMBOL] = false
130+
}
131+
124132
const setupDevBundlerSpan = opts.startServerSpan
125133
? opts.startServerSpan.traceChild('setup-dev-bundler')
126134
: trace('setup-dev-bundler')
@@ -138,6 +146,7 @@ export async function initialize(opts: {
138146
turbo: !!process.env.TURBOPACK,
139147
port: opts.port,
140148
onCleanup: opts.onCleanup,
149+
resetFetch,
141150
})
142151
)
143152

@@ -591,12 +600,12 @@ export async function initialize(opts: {
591600
let requestHandler: WorkerRequestHandler = requestHandlerImpl
592601
if (config.experimental.testProxy) {
593602
// Intercept fetch and other testmode apis.
594-
const {
595-
wrapRequestHandlerWorker,
596-
interceptTestApis,
597-
} = require('next/dist/experimental/testmode/server')
603+
const { wrapRequestHandlerWorker, interceptTestApis } =
604+
require('next/dist/experimental/testmode/server') as typeof import('next/src/experimental/testmode/server')
598605
requestHandler = wrapRequestHandlerWorker(requestHandler)
599606
interceptTestApis()
607+
// We treat the intercepted fetch as "original" fetch that should be reset to during HMR.
608+
originalFetch = globalThis.fetch
600609
}
601610
requestHandlers[opts.dir] = requestHandler
602611

packages/next/src/server/lib/router-utils/setup-dev-bundler.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ export type SetupOpts = {
104104
nextConfig: NextConfigComplete
105105
port: number
106106
onCleanup: (listener: () => Promise<void>) => void
107+
resetFetch: () => void
107108
}
108109

109110
export type ServerFields = {
@@ -122,6 +123,7 @@ export type ServerFields = {
122123
typeof import('./filesystem').buildCustomRoute
123124
>[]
124125
setAppIsrStatus?: (key: string, value: false | number | null) => void
126+
resetFetch?: () => void
125127
}
126128

127129
async function verifyTypeScript(opts: SetupOpts) {
@@ -152,7 +154,7 @@ export async function propagateServerField(
152154
}
153155

154156
async function startWatcher(opts: SetupOpts) {
155-
const { nextConfig, appDir, pagesDir, dir } = opts
157+
const { nextConfig, appDir, pagesDir, dir, resetFetch } = opts
156158
const { useFileSystemPublicRoutes } = nextConfig
157159
const usingTypeScript = await verifyTypeScript(opts)
158160

@@ -182,7 +184,7 @@ async function startWatcher(opts: SetupOpts) {
182184
})
183185

184186
const hotReloader: NextJsHotReloaderInterface = opts.turbo
185-
? await createHotReloaderTurbopack(opts, serverFields, distDir)
187+
? await createHotReloaderTurbopack(opts, serverFields, distDir, resetFetch)
186188
: new HotReloaderWebpack(opts.dir, {
187189
appDir,
188190
pagesDir,
@@ -193,6 +195,7 @@ async function startWatcher(opts: SetupOpts) {
193195
telemetry: opts.telemetry,
194196
rewrites: opts.fsChecker.rewrites,
195197
previewProps: opts.fsChecker.prerenderManifest.preview,
198+
resetFetch,
196199
})
197200

198201
await hotReloader.start()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import React from 'react'
2+
import { ReactNode } from 'react'
3+
4+
const magicNumber = Math.random()
5+
const originalFetch = globalThis.fetch
6+
7+
if (originalFetch.name === 'monkeyPatchedFetch') {
8+
throw new Error(
9+
'Patching over already patched fetch. This creates a memory leak.'
10+
)
11+
}
12+
13+
globalThis.fetch = async function monkeyPatchedFetch(
14+
resource: URL | RequestInfo,
15+
options?: RequestInit
16+
) {
17+
const request = new Request(resource)
18+
19+
if (request.url === 'http://fake.url/secret') {
20+
return new Response('monkey patching is fun')
21+
}
22+
23+
if (request.url === 'http://fake.url/magic-number') {
24+
return new Response(magicNumber.toString())
25+
}
26+
27+
return originalFetch(resource, options)
28+
}
29+
30+
export default function Root({ children }: { children: ReactNode }) {
31+
return (
32+
<html>
33+
<body>{children}</body>
34+
</html>
35+
)
36+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export default async function Page() {
2+
const secret = (await fetch('http://fake.url/secret').then((res) =>
3+
res.text()
4+
)) as any
5+
const magicNumber = (await fetch('http://fake.url/magic-number').then((res) =>
6+
res.text()
7+
)) as any
8+
9+
return (
10+
<>
11+
<div id="update">touch to trigger HMR</div>
12+
<div id="secret">{secret}</div>
13+
<div id="magic-number">{magicNumber}</div>
14+
</>
15+
)
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { nextTestSetup } from 'e2e-utils'
2+
import { retry } from 'next-test-utils'
3+
4+
import cheerio from 'cheerio'
5+
6+
describe('dev-fetch-hmr', () => {
7+
const { next } = nextTestSetup({
8+
files: __dirname,
9+
})
10+
11+
it('should retain module level fetch patching', async () => {
12+
const html = await next.render('/')
13+
expect(html).toContain('monkey patching is fun')
14+
15+
const magicNumber = cheerio.load(html)('#magic-number').text()
16+
17+
const html2 = await next.render('/')
18+
expect(html2).toContain('monkey patching is fun')
19+
const magicNumber2 = cheerio.load(html2)('#magic-number').text()
20+
// Module was not re-evaluated
21+
expect(magicNumber2).toBe(magicNumber)
22+
const update = cheerio.load(html2)('#update').text()
23+
expect(update).toBe('touch to trigger HMR')
24+
25+
// trigger HMR
26+
await next.patchFile('app/page.tsx', (content) =>
27+
content.replace('touch to trigger HMR', 'touch to trigger HMR 2')
28+
)
29+
30+
await retry(async () => {
31+
const html3 = await next.render('/')
32+
const update2 = cheerio.load(html3)('#update').text()
33+
expect(update2).toBe('touch to trigger HMR 2')
34+
const magicNumber3 = cheerio.load(html3)('#magic-number').text()
35+
expect(html3).toContain('monkey patching is fun')
36+
// Module was re-evaluated
37+
expect(magicNumber3).not.toEqual(magicNumber)
38+
})
39+
})
40+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* @type {import('next').NextConfig}
3+
*/
4+
const nextConfig = {}
5+
6+
module.exports = nextConfig
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2017",
4+
"lib": ["dom", "dom.iterable", "esnext"],
5+
"allowJs": true,
6+
"skipLibCheck": true,
7+
"strict": false,
8+
"noEmit": true,
9+
"incremental": true,
10+
"module": "esnext",
11+
"esModuleInterop": true,
12+
"moduleResolution": "node",
13+
"resolveJsonModule": true,
14+
"isolatedModules": true,
15+
"jsx": "preserve",
16+
"plugins": [
17+
{
18+
"name": "next"
19+
}
20+
]
21+
},
22+
"include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
23+
"exclude": ["node_modules"]
24+
}

0 commit comments

Comments
 (0)