Skip to content

Commit 57baeec

Browse files
authored
Server Components HMR Cache (#67527)
This adds support for caching `fetch` responses in server components across HMR refresh requests. The two main benefits are faster responses for those requests, and reduced costs for billed API calls during local development. **Implementation notes:** - The feature is guarded by the new experimental flag `serverComponentsHmrCache`. - The server components HMR cache is intentionally independent from the incremental cache. - Fetched responses are written to the cache after every original fetch call, regardless of the cache settings (specifically including `no-store`). - Cached responses are read from the cache only for HMR refresh requests, potentially also short-cutting the incremental cache. - The HMR refresh requests are marked by the client with the newly introduced `Next-HMR-Refresh` header. - I shied away from further extending `renderOpts`. The alternative of adding another parameter to `renderToHTMLOrFlight` might not necessarily be better though. - This includes a refactoring to steer away from the "fast refresh" wording, since this is a separate (but related) [React feature](facebook/react#16604 (comment)) (also build on top of HMR). x-ref: #48481
1 parent 4498f95 commit 57baeec

36 files changed

+559
-136
lines changed

packages/next/src/client/components/app-router-headers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ export const RSC_HEADER = 'RSC' as const
22
export const ACTION_HEADER = 'Next-Action' as const
33
export const NEXT_ROUTER_STATE_TREE_HEADER = 'Next-Router-State-Tree' as const
44
export const NEXT_ROUTER_PREFETCH_HEADER = 'Next-Router-Prefetch' as const
5+
export const NEXT_HMR_REFRESH_HEADER = 'Next-HMR-Refresh' as const
56
export const NEXT_URL = 'Next-Url' as const
67
export const RSC_CONTENT_TYPE_HEADER = 'text/x-component' as const
78

89
export const FLIGHT_HEADERS = [
910
RSC_HEADER,
1011
NEXT_ROUTER_STATE_TREE_HEADER,
1112
NEXT_ROUTER_PREFETCH_HEADER,
13+
NEXT_HMR_REFRESH_HEADER,
1214
] as const
1315

1416
export const NEXT_RSC_UNION_QUERY = '_rsc' as const

packages/next/src/client/components/app-router.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import type {
2020
AppRouterInstance,
2121
} from '../../shared/lib/app-router-context.shared-runtime'
2222
import {
23-
ACTION_FAST_REFRESH,
23+
ACTION_HMR_REFRESH,
2424
ACTION_NAVIGATE,
2525
ACTION_PREFETCH,
2626
ACTION_REFRESH,
@@ -317,15 +317,15 @@ function Router({
317317
})
318318
})
319319
},
320-
fastRefresh: () => {
320+
hmrRefresh: () => {
321321
if (process.env.NODE_ENV !== 'development') {
322322
throw new Error(
323-
'fastRefresh can only be used in development mode. Please use refresh instead.'
323+
'hmrRefresh can only be used in development mode. Please use refresh instead.'
324324
)
325325
} else {
326326
startTransition(() => {
327327
dispatch({
328-
type: ACTION_FAST_REFRESH,
328+
type: ACTION_HMR_REFRESH,
329329
origin: window.location.origin,
330330
})
331331
})

packages/next/src/client/components/react-dev-overlay/app/hot-reloader-client.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,7 @@ function processMessage(
427427
return window.location.reload()
428428
}
429429
startTransition(() => {
430-
router.fastRefresh()
430+
router.hmrRefresh()
431431
dispatcher.onRefresh()
432432
})
433433
reportHmrLatency(sendMessage, [])
@@ -455,7 +455,7 @@ function processMessage(
455455
case HMR_ACTIONS_SENT_TO_BROWSER.ADDED_PAGE:
456456
case HMR_ACTIONS_SENT_TO_BROWSER.REMOVED_PAGE: {
457457
// TODO-APP: potentially only refresh if the currently viewed page was added/removed.
458-
return router.fastRefresh()
458+
return router.hmrRefresh()
459459
}
460460
case HMR_ACTIONS_SENT_TO_BROWSER.SERVER_ERROR: {
461461
const { errorJSON } = obj

packages/next/src/client/components/request-async-storage.external.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { ReadonlyRequestCookies } from '../../server/web/spec-extension/ada
88
import { requestAsyncStorage } from './request-async-storage-instance' with { 'turbopack-transition': 'next-shared' }
99
import type { DeepReadonly } from '../../shared/lib/deep-readonly'
1010
import type { AfterContext } from '../../server/after/after-context'
11+
import type { ServerComponentsHmrCache } from '../../server/response-cache'
1112

1213
export interface RequestStore {
1314
/**
@@ -36,6 +37,8 @@ export interface RequestStore {
3637
>
3738
readonly assetPrefix: string
3839
readonly afterContext: AfterContext | undefined
40+
readonly isHmrRefresh?: boolean
41+
readonly serverComponentsHmrCache?: ServerComponentsHmrCache
3942
}
4043

4144
export type RequestAsyncStorage = AsyncLocalStorage<RequestStore>

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
RSC_HEADER,
2525
RSC_CONTENT_TYPE_HEADER,
2626
NEXT_DID_POSTPONE_HEADER,
27+
NEXT_HMR_REFRESH_HEADER,
2728
} from '../app-router-headers'
2829
import { callServer } from '../../app-call-server'
2930
import { PrefetchKind } from './router-reducer-types'
@@ -34,6 +35,7 @@ export interface FetchServerResponseOptions {
3435
readonly nextUrl: string | null
3536
readonly buildId: string
3637
readonly prefetchKind?: PrefetchKind
38+
readonly isHmrRefresh?: boolean
3739
}
3840

3941
export type FetchServerResponseResult = [
@@ -79,6 +81,7 @@ export async function fetchServerResponse(
7981
[NEXT_ROUTER_STATE_TREE_HEADER]: string
8082
[NEXT_URL]?: string
8183
[NEXT_ROUTER_PREFETCH_HEADER]?: '1'
84+
[NEXT_HMR_REFRESH_HEADER]?: '1'
8285
// A header that is only added in test mode to assert on fetch priority
8386
'Next-Test-Fetch-Priority'?: RequestInit['priority']
8487
} = {
@@ -100,6 +103,10 @@ export async function fetchServerResponse(
100103
headers[NEXT_ROUTER_PREFETCH_HEADER] = '1'
101104
}
102105

106+
if (process.env.NODE_ENV === 'development' && options.isHmrRefresh) {
107+
headers[NEXT_HMR_REFRESH_HEADER] = '1'
108+
}
109+
103110
if (nextUrl) {
104111
headers[NEXT_URL] = nextUrl
105112
}

packages/next/src/client/components/router-reducer/reducers/fast-refresh-reducer.ts renamed to packages/next/src/client/components/router-reducer/reducers/hmr-refresh-reducer.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { isNavigatingToNewRootLayout } from '../is-navigating-to-new-root-layout
55
import type {
66
ReadonlyReducerState,
77
ReducerState,
8-
FastRefreshAction,
8+
HmrRefreshAction,
99
Mutable,
1010
} from '../router-reducer-types'
1111
import { handleExternalUrl } from './navigate-reducer'
@@ -17,9 +17,9 @@ import { handleSegmentMismatch } from '../handle-segment-mismatch'
1717
import { hasInterceptionRouteInCurrentTree } from './has-interception-route-in-current-tree'
1818

1919
// A version of refresh reducer that keeps the cache around instead of wiping all of it.
20-
function fastRefreshReducerImpl(
20+
function hmrRefreshReducerImpl(
2121
state: ReadonlyReducerState,
22-
action: FastRefreshAction
22+
action: HmrRefreshAction
2323
): ReducerState {
2424
const { origin } = action
2525
const mutable: Mutable = {}
@@ -38,6 +38,7 @@ function fastRefreshReducerImpl(
3838
flightRouterState: [state.tree[0], state.tree[1], state.tree[2], 'refetch'],
3939
nextUrl: includeNextUrl ? state.nextUrl : null,
4040
buildId: state.buildId,
41+
isHmrRefresh: true,
4142
})
4243

4344
return cache.lazyData.then(
@@ -114,14 +115,14 @@ function fastRefreshReducerImpl(
114115
)
115116
}
116117

117-
function fastRefreshReducerNoop(
118+
function hmrRefreshReducerNoop(
118119
state: ReadonlyReducerState,
119-
_action: FastRefreshAction
120+
_action: HmrRefreshAction
120121
): ReducerState {
121122
return state
122123
}
123124

124-
export const fastRefreshReducer =
125+
export const hmrRefreshReducer =
125126
process.env.NODE_ENV === 'production'
126-
? fastRefreshReducerNoop
127-
: fastRefreshReducerImpl
127+
? hmrRefreshReducerNoop
128+
: hmrRefreshReducerImpl

packages/next/src/client/components/router-reducer/router-reducer-types.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export const ACTION_NAVIGATE = 'navigate'
1010
export const ACTION_RESTORE = 'restore'
1111
export const ACTION_SERVER_PATCH = 'server-patch'
1212
export const ACTION_PREFETCH = 'prefetch'
13-
export const ACTION_FAST_REFRESH = 'fast-refresh'
13+
export const ACTION_HMR_REFRESH = 'hmr-refresh'
1414
export const ACTION_SERVER_ACTION = 'server-action'
1515

1616
export type RouterChangeByServerResponse = ({
@@ -55,8 +55,8 @@ export interface RefreshAction {
5555
origin: Location['origin']
5656
}
5757

58-
export interface FastRefreshAction {
59-
type: typeof ACTION_FAST_REFRESH
58+
export interface HmrRefreshAction {
59+
type: typeof ACTION_HMR_REFRESH
6060
origin: Location['origin']
6161
}
6262

@@ -268,7 +268,7 @@ export type ReducerActions = Readonly<
268268
| RestoreAction
269269
| ServerPatchAction
270270
| PrefetchAction
271-
| FastRefreshAction
271+
| HmrRefreshAction
272272
| ServerActionAction
273273
>
274274

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
ACTION_RESTORE,
55
ACTION_REFRESH,
66
ACTION_PREFETCH,
7-
ACTION_FAST_REFRESH,
7+
ACTION_HMR_REFRESH,
88
ACTION_SERVER_ACTION,
99
} from './router-reducer-types'
1010
import type {
@@ -17,7 +17,7 @@ import { serverPatchReducer } from './reducers/server-patch-reducer'
1717
import { restoreReducer } from './reducers/restore-reducer'
1818
import { refreshReducer } from './reducers/refresh-reducer'
1919
import { prefetchReducer } from './reducers/prefetch-reducer'
20-
import { fastRefreshReducer } from './reducers/fast-refresh-reducer'
20+
import { hmrRefreshReducer } from './reducers/hmr-refresh-reducer'
2121
import { serverActionReducer } from './reducers/server-action-reducer'
2222

2323
/**
@@ -40,8 +40,8 @@ function clientReducer(
4040
case ACTION_REFRESH: {
4141
return refreshReducer(state, action)
4242
}
43-
case ACTION_FAST_REFRESH: {
44-
return fastRefreshReducer(state, action)
43+
case ACTION_HMR_REFRESH: {
44+
return hmrRefreshReducer(state, action)
4545
}
4646
case ACTION_PREFETCH: {
4747
return prefetchReducer(state, action)

packages/next/src/server/after/after-context.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,8 @@ const createMockRequestStore = (afterContext: AfterContext): RequestStore => {
468468
assetPrefix: '',
469469
reactLoadableManifest: {},
470470
draftMode: undefined,
471+
isHmrRefresh: false,
472+
serverComponentsHmrCache: undefined,
471473
}
472474

473475
return new Proxy(partialStore as RequestStore, {

packages/next/src/server/after/after-context.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ function wrapRequestStoreForAfterCallbacks(
149149
assetPrefix: requestStore.assetPrefix,
150150
reactLoadableManifest: requestStore.reactLoadableManifest,
151151
afterContext: requestStore.afterContext,
152+
isHmrRefresh: requestStore.isHmrRefresh,
153+
serverComponentsHmrCache: requestStore.serverComponentsHmrCache,
152154
}
153155
}
154156

packages/next/src/server/app-render/app-render.tsx

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
} from '../stream-utils/node-web-streams-helper'
4040
import { stripInternalQueries } from '../internal-utils'
4141
import {
42+
NEXT_HMR_REFRESH_HEADER,
4243
NEXT_ROUTER_PREFETCH_HEADER,
4344
NEXT_ROUTER_STATE_TREE_HEADER,
4445
NEXT_URL,
@@ -120,6 +121,7 @@ import { isNodeNextRequest } from '../base-http/helpers'
120121
import { parseParameter } from '../../shared/lib/router/utils/route-regex'
121122
import { parseRelativeUrl } from '../../shared/lib/router/utils/parse-relative-url'
122123
import AppRouter from '../../client/components/app-router'
124+
import type { ServerComponentsHmrCache } from '../response-cache'
123125

124126
export type GetDynamicParamFromSegment = (
125127
// [slug] / [[slug]] / [...slug]
@@ -136,6 +138,7 @@ type AppRenderBaseContext = {
136138
requestStore: RequestStore
137139
componentMod: AppPageModule
138140
renderOpts: RenderOpts
141+
parsedRequestHeaders: ParsedRequestHeaders
139142
}
140143

141144
export type GenerateFlight = typeof generateFlight
@@ -172,6 +175,7 @@ interface ParsedRequestHeaders {
172175
*/
173176
readonly flightRouterState: FlightRouterState | undefined
174177
readonly isPrefetchRequest: boolean
178+
readonly isHmrRefresh: boolean
175179
readonly isRSCRequest: boolean
176180
readonly nonce: string | undefined
177181
}
@@ -183,6 +187,9 @@ function parseRequestHeaders(
183187
const isPrefetchRequest =
184188
headers[NEXT_ROUTER_PREFETCH_HEADER.toLowerCase()] !== undefined
185189

190+
const isHmrRefresh =
191+
headers[NEXT_HMR_REFRESH_HEADER.toLowerCase()] !== undefined
192+
186193
const isRSCRequest = headers[RSC_HEADER.toLowerCase()] !== undefined
187194

188195
const shouldProvideFlightRouterState =
@@ -201,7 +208,13 @@ function parseRequestHeaders(
201208
const nonce =
202209
typeof csp === 'string' ? getScriptNonceFromHeader(csp) : undefined
203210

204-
return { flightRouterState, isPrefetchRequest, isRSCRequest, nonce }
211+
return {
212+
flightRouterState,
213+
isPrefetchRequest,
214+
isHmrRefresh,
215+
isRSCRequest,
216+
nonce,
217+
}
205218
}
206219

207220
function createNotFoundLoaderTree(loaderTree: LoaderTree): LoaderTree {
@@ -742,7 +755,7 @@ async function renderToHTMLOrFlightImpl(
742755
const digestErrorsMap: Map<string, DigestedError> = new Map()
743756
const allCapturedErrors: Error[] = []
744757
const isNextExport = !!renderOpts.nextExport
745-
const { staticGenerationStore, requestStore } = baseCtx
758+
const { staticGenerationStore, requestStore, parsedRequestHeaders } = baseCtx
746759
const { isStaticGeneration } = staticGenerationStore
747760

748761
/**
@@ -872,9 +885,7 @@ async function renderToHTMLOrFlightImpl(
872885
stripInternalQueries(query)
873886

874887
const { flightRouterState, isPrefetchRequest, isRSCRequest, nonce } =
875-
// We read these values from the request object as, in certain cases,
876-
// base-server will strip them to opt into different rendering behavior.
877-
parseRequestHeaders(req.headers, { isRoutePPREnabled })
888+
parsedRequestHeaders
878889

879890
/**
880891
* The metadata items array created in next-app-loader with all relevant information
@@ -1542,25 +1553,42 @@ export type AppPageRender = (
15421553
res: BaseNextResponse,
15431554
pagePath: string,
15441555
query: NextParsedUrlQuery,
1545-
renderOpts: RenderOpts
1556+
renderOpts: RenderOpts,
1557+
serverComponentsHmrCache?: ServerComponentsHmrCache
15461558
) => Promise<RenderResult<AppPageRenderResultMetadata>>
15471559

15481560
export const renderToHTMLOrFlight: AppPageRender = (
15491561
req,
15501562
res,
15511563
pagePath,
15521564
query,
1553-
renderOpts
1565+
renderOpts,
1566+
serverComponentsHmrCache
15541567
) => {
15551568
if (!req.url) {
15561569
throw new Error('Invalid URL')
15571570
}
15581571

15591572
const url = parseRelativeUrl(req.url, undefined, false)
15601573

1574+
// We read these values from the request object as, in certain cases,
1575+
// base-server will strip them to opt into different rendering behavior.
1576+
const parsedRequestHeaders = parseRequestHeaders(req.headers, {
1577+
isRoutePPREnabled: renderOpts.experimental.isRoutePPREnabled === true,
1578+
})
1579+
1580+
const { isHmrRefresh } = parsedRequestHeaders
1581+
15611582
return withRequestStore(
15621583
renderOpts.ComponentMod.requestAsyncStorage,
1563-
{ req, url, res, renderOpts },
1584+
{
1585+
req,
1586+
url,
1587+
res,
1588+
renderOpts,
1589+
isHmrRefresh,
1590+
serverComponentsHmrCache,
1591+
},
15641592
(requestStore) =>
15651593
withStaticGenerationStore(
15661594
renderOpts.ComponentMod.staticGenerationAsyncStorage,
@@ -1581,6 +1609,7 @@ export const renderToHTMLOrFlight: AppPageRender = (
15811609
staticGenerationStore,
15821610
componentMod: renderOpts.ComponentMod,
15831611
renderOpts,
1612+
parsedRequestHeaders,
15841613
},
15851614
staticGenerationStore.requestEndedState || {}
15861615
)

0 commit comments

Comments
 (0)