Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle head.js on client-side navigation #42904

Merged
merged 9 commits into from Nov 16, 2022
52 changes: 44 additions & 8 deletions packages/next/client/components/app-router.tsx
Expand Up @@ -109,6 +109,37 @@ type AppRouterProps = {
assetPrefix: string
}

function findHeadInCache(
cache: CacheNode,
parallelRoutes: FlightRouterState[1]
): React.ReactNode {
const isLastItem = Object.keys(parallelRoutes).length === 0
if (isLastItem) {
return cache.head
}
for (const key in parallelRoutes) {
const [segment, childParallelRoutes] = parallelRoutes[key]
const childSegmentMap = cache.parallelRoutes.get(key)
if (!childSegmentMap) {
continue
}

const cacheKey = Array.isArray(segment) ? segment[1] : segment

const cacheNode = childSegmentMap.get(cacheKey)
if (!cacheNode) {
continue
}

const item = findHeadInCache(cacheNode, childParallelRoutes)
if (item) {
return item
}
}

return undefined
}

/**
* The global router that wraps the application components.
*/
Expand Down Expand Up @@ -147,6 +178,10 @@ function Router({
sync,
] = useReducerWithReduxDevtools(reducer, initialState)

const head = useMemo(() => {
return findHeadInCache(cache, tree[1])
}, [cache, tree])

useEffect(() => {
// Ensure initialParallelRoutes is cleaned up from memory once it's used.
initialParallelRoutes = null!
Expand Down Expand Up @@ -357,6 +392,13 @@ function Router({
}
}, [onPopState])

const content = (
<>
{head || initialHead}
{cache.subTreeData}
</>
)

return (
<PathnameContext.Provider value={pathname}>
<SearchParamsContext.Provider value={searchParams}>
Expand All @@ -378,15 +420,9 @@ function Router({
}}
>
{HotReloader ? (
<HotReloader assetPrefix={assetPrefix}>
{initialHead}
{cache.subTreeData}
</HotReloader>
<HotReloader assetPrefix={assetPrefix}>{content}</HotReloader>
) : (
<>
{initialHead}
{cache.subTreeData}
</>
content
)}
</LayoutRouterContext.Provider>
</AppRouterContext.Provider>
Expand Down
11 changes: 9 additions & 2 deletions packages/next/client/components/layout-router.tsx
Expand Up @@ -170,7 +170,7 @@ export function InnerLayoutRouter({
}

// When childNode is not available during rendering client-side we need to fetch it from the server.
if (!childNode) {
if (!childNode || childNode.status === CacheStates.LAZY_INITIALIZED) {
/**
* Router state with refetch marker added
*/
Expand All @@ -184,7 +184,14 @@ export function InnerLayoutRouter({
status: CacheStates.DATA_FETCH,
data: fetchServerResponse(new URL(url, location.origin), refetchTree),
subTreeData: null,
parallelRoutes: new Map(),
head:
childNode && childNode.status === CacheStates.LAZY_INITIALIZED
? childNode.head
: undefined,
parallelRoutes:
childNode && childNode.status === CacheStates.LAZY_INITIALIZED
? childNode.parallelRoutes
: new Map(),
})
// In the above case childNode was set on childNodes, so we have to get it from the cacheNodes again.
childNode = childNodes.get(path)
Expand Down
25 changes: 16 additions & 9 deletions packages/next/client/components/reducer.ts
Expand Up @@ -164,7 +164,6 @@ function fillCacheWithNewSubTreeData(
const existingChildCacheNode = existingChildSegmentMap.get(segmentForCache)
let childCacheNode = childSegmentMap.get(segmentForCache)

// In case of last segment start the fetch at this level and don't copy further down.
if (isLastEntry) {
if (
!childCacheNode ||
Expand Down Expand Up @@ -193,7 +192,7 @@ function fillCacheWithNewSubTreeData(
childCacheNode,
existingChildCacheNode,
flightDataPath[2],
/* flightDataPath[4] */ undefined
flightDataPath[4]
)

childSegmentMap.set(segmentForCache, childCacheNode)
Expand Down Expand Up @@ -309,12 +308,20 @@ function fillCacheWithPrefetchedSubTreeData(

if (isLastEntry) {
if (!existingChildCacheNode) {
existingChildSegmentMap.set(segmentForCache, {
const childCacheNode: CacheNode = {
status: CacheStates.READY,
data: null,
subTreeData: flightDataPath[3],
parallelRoutes: new Map(),
})
}

fillLazyItemsTillLeafWithHead(
childCacheNode,
existingChildCacheNode,
flightDataPath[2],
flightDataPath[4]
)
existingChildSegmentMap.set(segmentForCache, childCacheNode)
}

return
Expand Down Expand Up @@ -997,7 +1004,6 @@ function clientReducer(
const flightDataPath = flightData[0]

// The one before last item is the router state tree patch
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [treePatch, subTreeData, head] = flightDataPath.slice(-3)

// Path without the last segment, router state, and the subTreeData
Expand Down Expand Up @@ -1027,6 +1033,7 @@ function clientReducer(

if (flightDataPath.length === 3) {
cache.subTreeData = subTreeData
fillLazyItemsTillLeafWithHead(cache, state.cache, treePatch, head)
} else {
// Copy subTreeData for the root node of the cache.
cache.subTreeData = state.cache.subTreeData
Expand Down Expand Up @@ -1124,7 +1131,6 @@ function clientReducer(

// Slices off the last segment (which is at -4) as it doesn't exist in the tree yet
const flightSegmentPath = flightDataPath.slice(0, -4)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [treePatch, subTreeData, head] = flightDataPath.slice(-3)

const newTree = applyRouterStatePatchToTree(
Expand Down Expand Up @@ -1152,6 +1158,7 @@ function clientReducer(
// Root refresh
if (flightDataPath.length === 3) {
cache.subTreeData = subTreeData
fillLazyItemsTillLeafWithHead(cache, state.cache, treePatch, head)
} else {
// Copy subTreeData for the root node of the cache.
cache.subTreeData = state.cache.subTreeData
Expand Down Expand Up @@ -1276,7 +1283,7 @@ function clientReducer(
}

// Given the path can only have two items the items are only the router state and subTreeData for the root.
const [treePatch, subTreeData] = flightDataPath
const [treePatch, subTreeData, head] = flightDataPath
const newTree = applyRouterStatePatchToTree(
// TODO-APP: remove ''
[''],
Expand All @@ -1302,6 +1309,7 @@ function clientReducer(

// Set subTreeData for the root node of the cache.
cache.subTreeData = subTreeData
fillLazyItemsTillLeafWithHead(cache, state.cache, treePatch, head)

return {
// Set href, this doesn't reuse the state.canonicalUrl as because of concurrent rendering the href might change between dispatching and applying.
Expand Down Expand Up @@ -1334,8 +1342,7 @@ function clientReducer(
const flightDataPath = flightData[0]

// The one before last item is the router state tree patch
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [treePatch, subTreeData, head] = flightDataPath.slice(-3)
const [treePatch, subTreeData] = flightDataPath.slice(-3)

// TODO-APP: Verify if `null` can't be returned from user code.
// If subTreeData is null the prefetch did not provide a component tree.
Expand Down
12 changes: 9 additions & 3 deletions packages/next/server/app-render.tsx
Expand Up @@ -516,10 +516,11 @@ export type FlightDataPath =
// Looks somewhat like this
| [
// Holds full path to the segment.
...FlightSegmentPath,
...FlightSegmentPath[],
/* segment of the rendered slice: */ Segment,
/* treePatch */ FlightRouterState,
/* subTreeData: */ React.ReactNode | null // Can be null during prefetch if there's no loading component
/* subTreeData: */ React.ReactNode | null, // Can be null during prefetch if there's no loading component
/* head */ React.ReactNode | null
]

/**
Expand Down Expand Up @@ -1381,13 +1382,15 @@ export async function renderToHTMLOrFlight(
isFirst,
flightRouterState,
parentRendered,
rscPayloadHead,
}: {
createSegmentPath: CreateSegmentPath
loaderTreeToFilter: LoaderTree
parentParams: { [key: string]: string | string[] }
isFirst: boolean
flightRouterState?: FlightRouterState
parentRendered?: boolean
rscPayloadHead: React.ReactNode
}): Promise<FlightDataPath> => {
const [segment, parallelRoutes] = loaderTreeToFilter
const parallelRoutesKeys = Object.keys(parallelRoutes)
Expand Down Expand Up @@ -1444,7 +1447,7 @@ export async function renderToHTMLOrFlight(
).Component
),
isPrefetch && !Boolean(loaderTreeToFilter[2].loading) ? null : (
<>{null}</> // TODO: change this to head tags.
<>{rscPayloadHead}</>
),
]
}
Expand All @@ -1467,6 +1470,7 @@ export async function renderToHTMLOrFlight(
flightRouterState && flightRouterState[1][parallelRouteKey],
parentRendered: parentRendered || renderComponentsOnThisLevel,
isFirst: false,
rscPayloadHead,
})

if (typeof path[path.length - 1] !== 'string') {
Expand All @@ -1477,6 +1481,7 @@ export async function renderToHTMLOrFlight(
return [actualSegment]
}

const rscPayloadHead = await resolveHead(loaderTree, {})
// Flight data that is going to be passed to the browser.
// Currently a single item array but in the future multiple patches might be combined in a single request.
const flightData: FlightData = [
Expand All @@ -1488,6 +1493,7 @@ export async function renderToHTMLOrFlight(
parentParams: {},
flightRouterState: providedFlightRouterState,
isFirst: true,
rscPayloadHead,
})
).slice(1),
]
Expand Down
22 changes: 22 additions & 0 deletions test/e2e/app-dir/head.test.ts
Expand Up @@ -3,6 +3,7 @@ import cheerio from 'cheerio'
import { createNext, FileRef } from 'e2e-utils'
import { NextInstance } from 'test/lib/next-modes/base'
import { renderViaHTTP } from 'next-test-utils'
import webdriver from 'next-webdriver'

describe('app dir head', () => {
if ((global as any).isNextDeploy) {
Expand Down Expand Up @@ -91,6 +92,27 @@ describe('app dir head', () => {
headTags.find((el) => el.attribs.src === '/another.js')
).toBeTruthy()
})

it('should apply head when navigating client-side', async () => {
const browser = await webdriver(next.url, '/')

const getTitle = () => browser.elementByCss('title').text()

expect(await getTitle()).toBe('hello from index')
await browser
.elementByCss('#to-blog')
.click()
.waitForElementByCss('#layout', 2000)

expect(await getTitle()).toBe('hello from blog layout')
await browser.back().waitForElementByCss('#to-blog', 2000)
expect(await getTitle()).toBe('hello from index')
await browser
.elementByCss('#to-blog-slug')
.click()
.waitForElementByCss('#layout', 2000)
expect(await getTitle()).toBe('hello from dynamic blog page post-1')
})
}

runTests()
Expand Down