Skip to content

Commit

Permalink
Move client build ID to a global variable (#72592)
Browse files Browse the repository at this point in the history
When performing RSC requests, if the incoming data has a different build
ID than the client, we perform an MPA navigation/refresh to load the
updated build and ensure that the client and server in sync.

Currently we store the build ID in the router state, but becauase it's
always in lockstep with the app instance, it's not actually stateful. We
can store it in a global.

The ID gets assigned as a side-effect during app initialization. It
should never change after being set the first time. In practice, because
setAppBuildId is called before hydration starts, it will always get
assigned to the actual build ID before it's ever needed by a navigation.
(If for some reasons it didn't, due to a bug or race condition, then on
navigation the build comparision would fail and trigger an MPA
navigation.)

The logical flow of how the global is assigned is more convoluted than
it should be because we currently decode the Flight stream inside the
React render phase (via a hook), because that's required to propagate
resource hints correctly. Conceptually it would make more sense to
decode the stream before starting hydration and pass the decoded data to
the root component as props; this would also allow us to block hydration
until the id is set. But we'll need to address the hints problem first.

As a follow up, we should probably do the same thing for the App Router
reducer, which is already a global store in practice but is referenced
and passed everywhere as if it were owned by the React tree.
  • Loading branch information
acdlite authored Nov 12, 2024
1 parent 36269e7 commit 155471c
Show file tree
Hide file tree
Showing 18 changed files with 35 additions and 47 deletions.
22 changes: 22 additions & 0 deletions packages/next/src/client/app-build-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// This gets assigned as a side-effect during app initialization. Because it
// represents the build used to create the JS bundle, it should never change
// after being set, so we store it in a global variable.
//
// When performing RSC requests, if the incoming data has a different build ID,
// we perform an MPA navigation/refresh to load the updated build and ensure
// that the client and server in sync.

// Starts as an empty string. In practice, because setAppBuildId is called
// during initialization before hydration starts, this will always get
// reassigned to the actual build ID before it's ever needed by a navigation.
// If for some reasons it didn't, due to a bug or race condition, then on
// navigation the build comparision would fail and trigger an MPA navigation.
let globalBuildId: string = ''

export function setAppBuildId(buildId: string) {
globalBuildId = buildId
}

export function getAppBuildId(): string {
return globalBuildId
}
6 changes: 5 additions & 1 deletion packages/next/src/client/app-index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import AppRouter from './components/app-router'
import type { InitialRSCPayload } from '../server/app-render/types'
import { createInitialRouterState } from './components/router-reducer/create-initial-router-state'
import { MissingSlotContext } from '../shared/lib/app-router-context.shared-runtime'
import { setAppBuildId } from './app-build-id'

/// <reference types="react-dom/experimental" />

Expand Down Expand Up @@ -156,10 +157,13 @@ const pendingActionQueue: Promise<AppRouterActionQueue> = new Promise(
(resolve, reject) => {
initialServerResponse.then(
(initialRSCPayload) => {
// setAppBuildId should be called only once, during JS initialization
// and before any components have hydrated.
setAppBuildId(initialRSCPayload.b)

resolve(
createMutableActionQueue(
createInitialRouterState({
buildId: initialRSCPayload.b,
initialFlightData: initialRSCPayload.f,
initialCanonicalUrlParts: initialRSCPayload.c,
initialParallelRoutes: new Map(),
Expand Down
6 changes: 2 additions & 4 deletions packages/next/src/client/components/app-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -530,8 +530,7 @@ function Router({
}
}, [dispatch])

const { cache, tree, nextUrl, focusAndScrollRef, buildId } =
useUnwrapState(state)
const { cache, tree, nextUrl, focusAndScrollRef } = useUnwrapState(state)

const matchingHead = useMemo(() => {
return findHeadInCache(cache, tree[1])
Expand All @@ -555,13 +554,12 @@ function Router({

const globalLayoutRouterContext = useMemo(() => {
return {
buildId,
changeByServerResponse,
tree,
focusAndScrollRef,
nextUrl,
}
}, [buildId, changeByServerResponse, tree, focusAndScrollRef, nextUrl])
}, [changeByServerResponse, tree, focusAndScrollRef, nextUrl])

let head
if (matchingHead !== null) {
Expand Down
3 changes: 1 addition & 2 deletions packages/next/src/client/components/layout-router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ function InnerLayoutRouter({
throw new Error('invariant global layout router not mounted')
}

const { buildId, changeByServerResponse, tree: fullTree } = context
const { changeByServerResponse, tree: fullTree } = context

// Read segment path from the parallel router cache node.
let childNode = childNodes.get(cacheKey)
Expand Down Expand Up @@ -409,7 +409,6 @@ function InnerLayoutRouter({
{
flightRouterState: refetchTree,
nextUrl: includeNextUrl ? context.nextUrl : null,
buildId,
}
).then((serverResponse) => {
startTransition(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import type { CacheNode } from '../../../shared/lib/app-router-context.shared-ru
import { createInitialRouterState } from './create-initial-router-state'
import { PrefetchCacheEntryStatus, PrefetchKind } from './router-reducer-types'

const buildId = 'development'

const getInitialRouterStateTree = (): FlightRouterState => [
'',
{
Expand Down Expand Up @@ -34,7 +32,6 @@ describe('createInitialRouterState', () => {
const initialParallelRoutes: CacheNode['parallelRoutes'] = new Map()

const state = createInitialRouterState({
buildId,
initialFlightData: [
[initialTree, ['', children, {}, null], <title>Test</title>],
],
Expand All @@ -47,7 +44,6 @@ describe('createInitialRouterState', () => {
})

const state2 = createInitialRouterState({
buildId,
initialFlightData: [
[initialTree, ['', children, {}, null], <title>Test</title>],
],
Expand Down Expand Up @@ -106,7 +102,6 @@ describe('createInitialRouterState', () => {
}

const expected: ReturnType<typeof createInitialRouterState> = {
buildId,
tree: initialTree,
canonicalUrl: initialCanonicalUrl,
prefetchCache: new Map([
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { addRefreshMarkerToActiveParallelSegments } from './refetch-inactive-par
import { getFlightDataPartsFromPath } from '../../flight-data-helpers'

export interface InitialRouterStateParameters {
buildId: string
initialCanonicalUrlParts: string[]
initialParallelRoutes: CacheNode['parallelRoutes']
initialFlightData: FlightDataPath[]
Expand All @@ -21,7 +20,6 @@ export interface InitialRouterStateParameters {
}

export function createInitialRouterState({
buildId,
initialFlightData,
initialCanonicalUrlParts,
initialParallelRoutes,
Expand Down Expand Up @@ -81,7 +79,6 @@ export function createInitialRouterState({
}

const initialState = {
buildId,
tree: initialTree,
cache,
prefetchCache,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,11 @@ import {
normalizeFlightData,
type NormalizedFlightData,
} from '../../flight-data-helpers'
import { getAppBuildId } from '../../app-build-id'

export interface FetchServerResponseOptions {
readonly flightRouterState: FlightRouterState
readonly nextUrl: string | null
readonly buildId: string
readonly prefetchKind?: PrefetchKind
readonly isHmrRefresh?: boolean
}
Expand Down Expand Up @@ -88,7 +88,7 @@ export async function fetchServerResponse(
url: URL,
options: FetchServerResponseOptions
): Promise<FetchServerResponseResult> {
const { flightRouterState, nextUrl, buildId, prefetchKind } = options
const { flightRouterState, nextUrl, prefetchKind } = options

const headers: {
[RSC_HEADER]: '1'
Expand Down Expand Up @@ -221,7 +221,7 @@ export async function fetchServerResponse(
{ callServer, findSourceMapURL }
)

if (buildId !== response.b) {
if (getAppBuildId() !== response.b) {
return doMpaNavigation(res.url)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ export function handleMutable(
}

return {
buildId: state.buildId,
// Set href.
canonicalUrl: isNotUndefined(mutable.canonicalUrl)
? mutable.canonicalUrl === state.canonicalUrl
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,14 +160,10 @@ export function getOrCreatePrefetchCacheEntry({
url,
nextUrl,
tree,
buildId,
prefetchCache,
kind,
allowAliasing = true,
}: Pick<
ReadonlyReducerState,
'nextUrl' | 'prefetchCache' | 'tree' | 'buildId'
> & {
}: Pick<ReadonlyReducerState, 'nextUrl' | 'prefetchCache' | 'tree'> & {
url: URL
kind?: PrefetchKind
allowAliasing: boolean
Expand Down Expand Up @@ -206,7 +202,6 @@ export function getOrCreatePrefetchCacheEntry({
return createLazyPrefetchEntry({
tree,
url,
buildId,
nextUrl,
prefetchCache,
// If we didn't get an explicit prefetch kind, we want to set a temporary kind
Expand All @@ -232,7 +227,6 @@ export function getOrCreatePrefetchCacheEntry({
return createLazyPrefetchEntry({
tree,
url,
buildId,
nextUrl,
prefetchCache,
kind: kind || PrefetchKind.TEMPORARY,
Expand Down Expand Up @@ -316,12 +310,8 @@ function createLazyPrefetchEntry({
kind,
tree,
nextUrl,
buildId,
prefetchCache,
}: Pick<
ReadonlyReducerState,
'nextUrl' | 'tree' | 'buildId' | 'prefetchCache'
> & {
}: Pick<ReadonlyReducerState, 'nextUrl' | 'tree' | 'prefetchCache'> & {
url: URL
kind: PrefetchKind
}): PrefetchCacheEntry {
Expand All @@ -333,7 +323,6 @@ function createLazyPrefetchEntry({
fetchServerResponse(url, {
flightRouterState: tree,
nextUrl,
buildId,
prefetchKind: kind,
}).then((prefetchResponse) => {
// TODO: `fetchServerResponse` should be more tighly coupled to these prefetch cache operations
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ function hmrRefreshReducerImpl(
cache.lazyData = fetchServerResponse(new URL(href, origin), {
flightRouterState: [state.tree[0], state.tree[1], state.tree[2], 'refetch'],
nextUrl: includeNextUrl ? state.nextUrl : null,
buildId: state.buildId,
isHmrRefresh: true,
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,6 @@ export function navigateReducer(
url,
nextUrl: state.nextUrl,
tree: state.tree,
buildId: state.buildId,
prefetchCache: state.prefetchCache,
allowAliasing,
})
Expand Down Expand Up @@ -283,7 +282,6 @@ export function navigateReducer(
const dynamicRequest = fetchServerResponse(url, {
flightRouterState: currentTree,
nextUrl: state.nextUrl,
buildId: state.buildId,
})

listenForDynamicRequest(task, dynamicRequest)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ export function prefetchReducer(
prefetchCache: state.prefetchCache,
kind: action.kind,
tree: state.tree,
buildId: state.buildId,
allowAliasing: true,
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ export function refreshReducer(
'refetch',
],
nextUrl: includeNextUrl ? state.nextUrl : null,
buildId: state.buildId,
})

return cache.lazyData.then(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ export function restoreReducer(
: oldCache

return {
buildId: state.buildId,
// Set canonical url
canonicalUrl: href,
pushRef: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@ async function refreshInactiveParallelSegmentsImpl({
// and might not contain the data we need to patch in interception route data (such as dynamic params from a previous segment)
flightRouterState: [rootTree[0], rootTree[1], rootTree[2], 'refetch'],
nextUrl: includeNextUrl ? state.nextUrl : null,
buildId: state.buildId,
}
).then(({ flightData }) => {
if (typeof flightData !== 'string') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,11 +223,6 @@ export enum PrefetchCacheEntryStatus {
* Handles keeping the state of app-router.
*/
export type AppRouterState = {
/**
* The buildId is used to do a mpaNavigation when the server returns a different buildId.
* It is used to avoid issues where an older version of the app is loaded in the browser while the server has a new version.
*/
buildId: string
/**
* The router state, this is written into the history state in app-router using replaceState/pushState.
* - Has to be serializable as it is written into the history state.
Expand Down
2 changes: 0 additions & 2 deletions packages/next/src/server/app-render/app-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -906,7 +906,6 @@ function App<T>({
)

const initialState = createInitialRouterState({
buildId: response.b,
initialFlightData: response.f,
initialCanonicalUrlParts: response.c,
// location and initialParallelRoutes are not initialized in the SSR render
Expand Down Expand Up @@ -965,7 +964,6 @@ function AppWithoutContext<T>({
)

const initialState = createInitialRouterState({
buildId: response.b,
initialFlightData: response.f,
initialCanonicalUrlParts: response.c,
// location and initialParallelRoutes are not initialized in the SSR render
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,6 @@ export const LayoutRouterContext = React.createContext<{
} | null>(null)

export const GlobalLayoutRouterContext = React.createContext<{
buildId: string
tree: FlightRouterState
changeByServerResponse: RouterChangeByServerResponse
focusAndScrollRef: FocusAndScrollRef
Expand Down

0 comments on commit 155471c

Please sign in to comment.