Skip to content

Commit

Permalink
Server Components HMR Cache (#67527)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
unstubbable authored Jul 13, 2024
1 parent 4498f95 commit 57baeec
Show file tree
Hide file tree
Showing 36 changed files with 559 additions and 136 deletions.
2 changes: 2 additions & 0 deletions packages/next/src/client/components/app-router-headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ export const RSC_HEADER = 'RSC' as const
export const ACTION_HEADER = 'Next-Action' as const
export const NEXT_ROUTER_STATE_TREE_HEADER = 'Next-Router-State-Tree' as const
export const NEXT_ROUTER_PREFETCH_HEADER = 'Next-Router-Prefetch' as const
export const NEXT_HMR_REFRESH_HEADER = 'Next-HMR-Refresh' as const
export const NEXT_URL = 'Next-Url' as const
export const RSC_CONTENT_TYPE_HEADER = 'text/x-component' as const

export const FLIGHT_HEADERS = [
RSC_HEADER,
NEXT_ROUTER_STATE_TREE_HEADER,
NEXT_ROUTER_PREFETCH_HEADER,
NEXT_HMR_REFRESH_HEADER,
] as const

export const NEXT_RSC_UNION_QUERY = '_rsc' as const
Expand Down
8 changes: 4 additions & 4 deletions packages/next/src/client/components/app-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import type {
AppRouterInstance,
} from '../../shared/lib/app-router-context.shared-runtime'
import {
ACTION_FAST_REFRESH,
ACTION_HMR_REFRESH,
ACTION_NAVIGATE,
ACTION_PREFETCH,
ACTION_REFRESH,
Expand Down Expand Up @@ -317,15 +317,15 @@ function Router({
})
})
},
fastRefresh: () => {
hmrRefresh: () => {
if (process.env.NODE_ENV !== 'development') {
throw new Error(
'fastRefresh can only be used in development mode. Please use refresh instead.'
'hmrRefresh can only be used in development mode. Please use refresh instead.'
)
} else {
startTransition(() => {
dispatch({
type: ACTION_FAST_REFRESH,
type: ACTION_HMR_REFRESH,
origin: window.location.origin,
})
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,7 @@ function processMessage(
return window.location.reload()
}
startTransition(() => {
router.fastRefresh()
router.hmrRefresh()
dispatcher.onRefresh()
})
reportHmrLatency(sendMessage, [])
Expand Down Expand Up @@ -455,7 +455,7 @@ function processMessage(
case HMR_ACTIONS_SENT_TO_BROWSER.ADDED_PAGE:
case HMR_ACTIONS_SENT_TO_BROWSER.REMOVED_PAGE: {
// TODO-APP: potentially only refresh if the currently viewed page was added/removed.
return router.fastRefresh()
return router.hmrRefresh()
}
case HMR_ACTIONS_SENT_TO_BROWSER.SERVER_ERROR: {
const { errorJSON } = obj
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { ReadonlyRequestCookies } from '../../server/web/spec-extension/ada
import { requestAsyncStorage } from './request-async-storage-instance' with { 'turbopack-transition': 'next-shared' }
import type { DeepReadonly } from '../../shared/lib/deep-readonly'
import type { AfterContext } from '../../server/after/after-context'
import type { ServerComponentsHmrCache } from '../../server/response-cache'

export interface RequestStore {
/**
Expand Down Expand Up @@ -36,6 +37,8 @@ export interface RequestStore {
>
readonly assetPrefix: string
readonly afterContext: AfterContext | undefined
readonly isHmrRefresh?: boolean
readonly serverComponentsHmrCache?: ServerComponentsHmrCache
}

export type RequestAsyncStorage = AsyncLocalStorage<RequestStore>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
RSC_HEADER,
RSC_CONTENT_TYPE_HEADER,
NEXT_DID_POSTPONE_HEADER,
NEXT_HMR_REFRESH_HEADER,
} from '../app-router-headers'
import { callServer } from '../../app-call-server'
import { PrefetchKind } from './router-reducer-types'
Expand All @@ -34,6 +35,7 @@ export interface FetchServerResponseOptions {
readonly nextUrl: string | null
readonly buildId: string
readonly prefetchKind?: PrefetchKind
readonly isHmrRefresh?: boolean
}

export type FetchServerResponseResult = [
Expand Down Expand Up @@ -79,6 +81,7 @@ export async function fetchServerResponse(
[NEXT_ROUTER_STATE_TREE_HEADER]: string
[NEXT_URL]?: string
[NEXT_ROUTER_PREFETCH_HEADER]?: '1'
[NEXT_HMR_REFRESH_HEADER]?: '1'
// A header that is only added in test mode to assert on fetch priority
'Next-Test-Fetch-Priority'?: RequestInit['priority']
} = {
Expand All @@ -100,6 +103,10 @@ export async function fetchServerResponse(
headers[NEXT_ROUTER_PREFETCH_HEADER] = '1'
}

if (process.env.NODE_ENV === 'development' && options.isHmrRefresh) {
headers[NEXT_HMR_REFRESH_HEADER] = '1'
}

if (nextUrl) {
headers[NEXT_URL] = nextUrl
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { isNavigatingToNewRootLayout } from '../is-navigating-to-new-root-layout
import type {
ReadonlyReducerState,
ReducerState,
FastRefreshAction,
HmrRefreshAction,
Mutable,
} from '../router-reducer-types'
import { handleExternalUrl } from './navigate-reducer'
Expand All @@ -17,9 +17,9 @@ import { handleSegmentMismatch } from '../handle-segment-mismatch'
import { hasInterceptionRouteInCurrentTree } from './has-interception-route-in-current-tree'

// A version of refresh reducer that keeps the cache around instead of wiping all of it.
function fastRefreshReducerImpl(
function hmrRefreshReducerImpl(
state: ReadonlyReducerState,
action: FastRefreshAction
action: HmrRefreshAction
): ReducerState {
const { origin } = action
const mutable: Mutable = {}
Expand All @@ -38,6 +38,7 @@ function fastRefreshReducerImpl(
flightRouterState: [state.tree[0], state.tree[1], state.tree[2], 'refetch'],
nextUrl: includeNextUrl ? state.nextUrl : null,
buildId: state.buildId,
isHmrRefresh: true,
})

return cache.lazyData.then(
Expand Down Expand Up @@ -114,14 +115,14 @@ function fastRefreshReducerImpl(
)
}

function fastRefreshReducerNoop(
function hmrRefreshReducerNoop(
state: ReadonlyReducerState,
_action: FastRefreshAction
_action: HmrRefreshAction
): ReducerState {
return state
}

export const fastRefreshReducer =
export const hmrRefreshReducer =
process.env.NODE_ENV === 'production'
? fastRefreshReducerNoop
: fastRefreshReducerImpl
? hmrRefreshReducerNoop
: hmrRefreshReducerImpl
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const ACTION_NAVIGATE = 'navigate'
export const ACTION_RESTORE = 'restore'
export const ACTION_SERVER_PATCH = 'server-patch'
export const ACTION_PREFETCH = 'prefetch'
export const ACTION_FAST_REFRESH = 'fast-refresh'
export const ACTION_HMR_REFRESH = 'hmr-refresh'
export const ACTION_SERVER_ACTION = 'server-action'

export type RouterChangeByServerResponse = ({
Expand Down Expand Up @@ -55,8 +55,8 @@ export interface RefreshAction {
origin: Location['origin']
}

export interface FastRefreshAction {
type: typeof ACTION_FAST_REFRESH
export interface HmrRefreshAction {
type: typeof ACTION_HMR_REFRESH
origin: Location['origin']
}

Expand Down Expand Up @@ -268,7 +268,7 @@ export type ReducerActions = Readonly<
| RestoreAction
| ServerPatchAction
| PrefetchAction
| FastRefreshAction
| HmrRefreshAction
| ServerActionAction
>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
ACTION_RESTORE,
ACTION_REFRESH,
ACTION_PREFETCH,
ACTION_FAST_REFRESH,
ACTION_HMR_REFRESH,
ACTION_SERVER_ACTION,
} from './router-reducer-types'
import type {
Expand All @@ -17,7 +17,7 @@ import { serverPatchReducer } from './reducers/server-patch-reducer'
import { restoreReducer } from './reducers/restore-reducer'
import { refreshReducer } from './reducers/refresh-reducer'
import { prefetchReducer } from './reducers/prefetch-reducer'
import { fastRefreshReducer } from './reducers/fast-refresh-reducer'
import { hmrRefreshReducer } from './reducers/hmr-refresh-reducer'
import { serverActionReducer } from './reducers/server-action-reducer'

/**
Expand All @@ -40,8 +40,8 @@ function clientReducer(
case ACTION_REFRESH: {
return refreshReducer(state, action)
}
case ACTION_FAST_REFRESH: {
return fastRefreshReducer(state, action)
case ACTION_HMR_REFRESH: {
return hmrRefreshReducer(state, action)
}
case ACTION_PREFETCH: {
return prefetchReducer(state, action)
Expand Down
2 changes: 2 additions & 0 deletions packages/next/src/server/after/after-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,8 @@ const createMockRequestStore = (afterContext: AfterContext): RequestStore => {
assetPrefix: '',
reactLoadableManifest: {},
draftMode: undefined,
isHmrRefresh: false,
serverComponentsHmrCache: undefined,
}

return new Proxy(partialStore as RequestStore, {
Expand Down
2 changes: 2 additions & 0 deletions packages/next/src/server/after/after-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ function wrapRequestStoreForAfterCallbacks(
assetPrefix: requestStore.assetPrefix,
reactLoadableManifest: requestStore.reactLoadableManifest,
afterContext: requestStore.afterContext,
isHmrRefresh: requestStore.isHmrRefresh,
serverComponentsHmrCache: requestStore.serverComponentsHmrCache,
}
}

Expand Down
45 changes: 37 additions & 8 deletions packages/next/src/server/app-render/app-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
} from '../stream-utils/node-web-streams-helper'
import { stripInternalQueries } from '../internal-utils'
import {
NEXT_HMR_REFRESH_HEADER,
NEXT_ROUTER_PREFETCH_HEADER,
NEXT_ROUTER_STATE_TREE_HEADER,
NEXT_URL,
Expand Down Expand Up @@ -120,6 +121,7 @@ import { isNodeNextRequest } from '../base-http/helpers'
import { parseParameter } from '../../shared/lib/router/utils/route-regex'
import { parseRelativeUrl } from '../../shared/lib/router/utils/parse-relative-url'
import AppRouter from '../../client/components/app-router'
import type { ServerComponentsHmrCache } from '../response-cache'

export type GetDynamicParamFromSegment = (
// [slug] / [[slug]] / [...slug]
Expand All @@ -136,6 +138,7 @@ type AppRenderBaseContext = {
requestStore: RequestStore
componentMod: AppPageModule
renderOpts: RenderOpts
parsedRequestHeaders: ParsedRequestHeaders
}

export type GenerateFlight = typeof generateFlight
Expand Down Expand Up @@ -172,6 +175,7 @@ interface ParsedRequestHeaders {
*/
readonly flightRouterState: FlightRouterState | undefined
readonly isPrefetchRequest: boolean
readonly isHmrRefresh: boolean
readonly isRSCRequest: boolean
readonly nonce: string | undefined
}
Expand All @@ -183,6 +187,9 @@ function parseRequestHeaders(
const isPrefetchRequest =
headers[NEXT_ROUTER_PREFETCH_HEADER.toLowerCase()] !== undefined

const isHmrRefresh =
headers[NEXT_HMR_REFRESH_HEADER.toLowerCase()] !== undefined

const isRSCRequest = headers[RSC_HEADER.toLowerCase()] !== undefined

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

return { flightRouterState, isPrefetchRequest, isRSCRequest, nonce }
return {
flightRouterState,
isPrefetchRequest,
isHmrRefresh,
isRSCRequest,
nonce,
}
}

function createNotFoundLoaderTree(loaderTree: LoaderTree): LoaderTree {
Expand Down Expand Up @@ -742,7 +755,7 @@ async function renderToHTMLOrFlightImpl(
const digestErrorsMap: Map<string, DigestedError> = new Map()
const allCapturedErrors: Error[] = []
const isNextExport = !!renderOpts.nextExport
const { staticGenerationStore, requestStore } = baseCtx
const { staticGenerationStore, requestStore, parsedRequestHeaders } = baseCtx
const { isStaticGeneration } = staticGenerationStore

/**
Expand Down Expand Up @@ -872,9 +885,7 @@ async function renderToHTMLOrFlightImpl(
stripInternalQueries(query)

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

/**
* The metadata items array created in next-app-loader with all relevant information
Expand Down Expand Up @@ -1542,25 +1553,42 @@ export type AppPageRender = (
res: BaseNextResponse,
pagePath: string,
query: NextParsedUrlQuery,
renderOpts: RenderOpts
renderOpts: RenderOpts,
serverComponentsHmrCache?: ServerComponentsHmrCache
) => Promise<RenderResult<AppPageRenderResultMetadata>>

export const renderToHTMLOrFlight: AppPageRender = (
req,
res,
pagePath,
query,
renderOpts
renderOpts,
serverComponentsHmrCache
) => {
if (!req.url) {
throw new Error('Invalid URL')
}

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

// We read these values from the request object as, in certain cases,
// base-server will strip them to opt into different rendering behavior.
const parsedRequestHeaders = parseRequestHeaders(req.headers, {
isRoutePPREnabled: renderOpts.experimental.isRoutePPREnabled === true,
})

const { isHmrRefresh } = parsedRequestHeaders

return withRequestStore(
renderOpts.ComponentMod.requestAsyncStorage,
{ req, url, res, renderOpts },
{
req,
url,
res,
renderOpts,
isHmrRefresh,
serverComponentsHmrCache,
},
(requestStore) =>
withStaticGenerationStore(
renderOpts.ComponentMod.staticGenerationAsyncStorage,
Expand All @@ -1581,6 +1609,7 @@ export const renderToHTMLOrFlight: AppPageRender = (
staticGenerationStore,
componentMod: renderOpts.ComponentMod,
renderOpts,
parsedRequestHeaders,
},
staticGenerationStore.requestEndedState || {}
)
Expand Down
Loading

0 comments on commit 57baeec

Please sign in to comment.