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

Add head handling #41768

Merged
merged 8 commits into from Oct 25, 2022
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/next/build/webpack/loaders/next-app-loader.ts
Expand Up @@ -13,6 +13,7 @@ export const FILE_TYPES = {
template: 'template',
error: 'error',
loading: 'loading',
head: 'head',
'not-found': 'not-found',
} as const

Expand Down
9 changes: 8 additions & 1 deletion packages/next/client/components/app-router.tsx
Expand Up @@ -95,6 +95,7 @@ let initialParallelRoutes: CacheNode['parallelRoutes'] =
const prefetched = new Set<string>()

type AppRouterProps = {
initialHead: ReactNode
initialTree: FlightRouterState
initialCanonicalUrl: string
children: ReactNode
Expand All @@ -105,11 +106,13 @@ type AppRouterProps = {
* The global router that wraps the application components.
*/
function Router({
initialHead,
initialTree,
initialCanonicalUrl,
children,
assetPrefix,
}: AppRouterProps) {
console.log({ initialHead })
ijjk marked this conversation as resolved.
Show resolved Hide resolved
const initialState = useMemo(() => {
return {
tree: initialTree,
Expand Down Expand Up @@ -361,10 +364,14 @@ function Router({
>
{HotReloader ? (
<HotReloader assetPrefix={assetPrefix}>
{initialHead}
{cache.subTreeData}
</HotReloader>
) : (
cache.subTreeData
<>
{initialHead}
{cache.subTreeData}
</>
)}
</LayoutRouterContext.Provider>
</AppRouterContext.Provider>
Expand Down
37 changes: 37 additions & 0 deletions packages/next/server/app-render.tsx
Expand Up @@ -847,6 +847,40 @@ export async function renderToHTMLOrFlight(
}
}

async function resolveHead(
[segment, parallelRoutes, { head }]: LoaderTree,
parentParams: { [key: string]: any }
): Promise<React.ReactNode> {
// Handle dynamic segment params.
const segmentParam = getDynamicParamFromSegment(segment)
/**
* Create object holding the parent params and current params
*/
const currentParams =
// Handle null case where dynamic param is optional
segmentParam && segmentParam.value !== null
? {
...parentParams,
[segmentParam.param]: segmentParam.value,
}
: // Pass through parent params to children
parentParams
for (const key in parallelRoutes) {
const childTree = parallelRoutes[key]
const returnedHead = await resolveHead(childTree, currentParams)
if (returnedHead) {
return returnedHead
}
}

if (head) {
const Head = await interopDefault(head())
return <Head params={currentParams} />
}

return null
}

const createFlightRouterStateFromLoaderTree = (
[segment, parallelRoutes, { layout }]: LoaderTree,
rootLayoutIncluded = false
Expand Down Expand Up @@ -1392,6 +1426,8 @@ export async function renderToHTMLOrFlight(
}
: {}

const initialHead = await resolveHead(loaderTree, {})

/**
* A new React Component that renders the provided React Component
* using Flight which can then be rendered to HTML.
Expand All @@ -1405,6 +1441,7 @@ export async function renderToHTMLOrFlight(
assetPrefix={assetPrefix}
initialCanonicalUrl={initialCanonicalUrl}
initialTree={initialTree}
initialHead={initialHead}
>
<ComponentTree />
</AppRouter>
Expand Down
95 changes: 95 additions & 0 deletions test/e2e/app-dir/head.test.ts
@@ -0,0 +1,95 @@
import path from 'path'
import cheerio from 'cheerio'
import { createNext, FileRef } from 'e2e-utils'
import { NextInstance } from 'test/lib/next-modes/base'
import { renderViaHTTP } from 'next-test-utils'

describe('app dir head', () => {
if ((global as any).isNextDeploy) {
it('should skip next deploy for now', () => {})
return
}

if (process.env.NEXT_TEST_REACT_VERSION === '^17') {
it('should skip for react v17', () => {})
return
}
let next: NextInstance

function runTests() {
beforeAll(async () => {
next = await createNext({
files: new FileRef(path.join(__dirname, 'head')),
dependencies: {
react: 'experimental',
'react-dom': 'experimental',
},
skipStart: true,
})

await next.start()
})
afterAll(() => next.destroy())

it('should use head from index page', async () => {
const html = await renderViaHTTP(next.url, '/')
const $ = cheerio.load(html)
const headTags = $('head').children().toArray()

expect(headTags.find((el) => el.attribs.src === '/hello.js')).toBeTruthy()
expect(
headTags.find((el) => el.attribs.src === '/another.js')
).toBeTruthy()
})

it('should not use head from layout when on page', async () => {
const html = await renderViaHTTP(next.url, '/blog')
const $ = cheerio.load(html)
const headTags = $('head').children().toArray()

expect(
headTags.find((el) => el.attribs.src === '/hello3.js')
).toBeTruthy()
expect(headTags.find((el) => el.attribs.src === '/hello1.js')).toBeFalsy()
expect(headTags.find((el) => el.attribs.src === '/hello2.js')).toBeFalsy()
expect(
headTags.find((el) => el.attribs.src === '/another.js')
).toBeTruthy()
})

it('should use head from layout when not on page', async () => {
const html = await renderViaHTTP(next.url, '/blog/about')
const $ = cheerio.load(html)
const headTags = $('head').children().toArray()

expect(
headTags.find((el) => el.attribs.src === '/hello1.js')
).toBeTruthy()
expect(
headTags.find((el) => el.attribs.src === '/hello2.js')
).toBeTruthy()
expect(
headTags.find((el) => el.attribs.src === '/another.js')
).toBeTruthy()
})

it('should pass params to head for dynamic path', async () => {
const html = await renderViaHTTP(next.url, '/blog/post-1')
const $ = cheerio.load(html)
const headTags = $('head').children().toArray()

expect(
headTags.find(
(el) =>
el.attribs.src === '/hello3.js' &&
el.attribs['data-slug'] === 'post-1'
)
).toBeTruthy()
expect(
headTags.find((el) => el.attribs.src === '/another.js')
).toBeTruthy()
})
}

runTests()
})
8 changes: 8 additions & 0 deletions test/e2e/app-dir/head/app/blog/[slug]/head.js
@@ -0,0 +1,8 @@
export default async function Head({ params }) {
return (
<>
<script async src="/hello3.js" data-slug={params.slug} />
<title>{`hello from dynamic blog page ${params.slug}`}</title>
</>
)
}
13 changes: 13 additions & 0 deletions test/e2e/app-dir/head/app/blog/[slug]/page.js
@@ -0,0 +1,13 @@
import Link from 'next/link'

export default function Page() {
return (
<>
<p id="page">dynamic blog page</p>
<Link href="/" id="to-index">
to /
</Link>
<br />
</>
)
}
13 changes: 13 additions & 0 deletions test/e2e/app-dir/head/app/blog/about/page.js
@@ -0,0 +1,13 @@
import Link from 'next/link'

export default function Page() {
return (
<>
<p id="page">blog about page</p>
<Link href="/" id="to-index">
to /
</Link>
<br />
</>
)
}
10 changes: 10 additions & 0 deletions test/e2e/app-dir/head/app/blog/head.js
@@ -0,0 +1,10 @@
export default async function Head() {
return (
<>
<script async src="/hello1.js" />
<script async src="/hello2.js" />
<title>hello from blog layout</title>
<meta name="description" content="a neat blog" />
</>
)
}
8 changes: 8 additions & 0 deletions test/e2e/app-dir/head/app/blog/layout.js
@@ -0,0 +1,8 @@
export default function Layout({ children }) {
return (
<>
<p id="layout">blog layout</p>
{children}
</>
)
}
22 changes: 22 additions & 0 deletions test/e2e/app-dir/head/app/blog/page.js
@@ -0,0 +1,22 @@
import Link from 'next/link'

export default function Page() {
return (
<>
<p id="page">blog page</p>
<Link href="/" id="to-index">
to /
</Link>
<br />
</>
)
}

export async function Head() {
return (
<>
<script async src="/hello3.js" />
<title>hello from blog page</title>
</>
)
}
9 changes: 9 additions & 0 deletions test/e2e/app-dir/head/app/head.js
@@ -0,0 +1,9 @@
export default async function Head() {
return (
<>
<script async src="/hello.js" />
<title>hello from index</title>
<meta name="description" content="an index page" />
</>
)
}
10 changes: 10 additions & 0 deletions test/e2e/app-dir/head/app/layout.js
@@ -0,0 +1,10 @@
export default function Root({ children }) {
return (
<html lang="en">
<head>
<script async src="/another.js" />
</head>
<body>{children}</body>
</html>
)
}
24 changes: 24 additions & 0 deletions test/e2e/app-dir/head/app/page.js
@@ -0,0 +1,24 @@
import Link from 'next/link'

export default function Page() {
return (
<>
<p id="page">index page</p>

<Link href="/blog" id="to-blog">
to /blog
</Link>
<br />

<Link href="/blog/post-1" id="to-blog-slug">
to /blog/post-1
</Link>
<br />

<Link href="/blog/about" id="to-blog-about">
to /blog/about
</Link>
<br />
</>
)
}
17 changes: 17 additions & 0 deletions test/e2e/app-dir/head/next.config.js
@@ -0,0 +1,17 @@
module.exports = {
experimental: {
appDir: true,
},
// assetPrefix: '/assets',
rewrites: async () => {
return {
// beforeFiles: [ { source: '/assets/:path*', destination: '/:path*' } ],
afterFiles: [
{
source: '/rewritten-to-dashboard',
destination: '/dashboard',
},
],
}
},
}
4 changes: 4 additions & 0 deletions test/e2e/app-dir/head/public/another.js
@@ -0,0 +1,4 @@
if (typeof window !== 'undefined') {
window.fromAnother = true
}
console.log('another')
4 changes: 4 additions & 0 deletions test/e2e/app-dir/head/public/hello.js
@@ -0,0 +1,4 @@
if (typeof window !== 'undefined') {
window.fromHello = true
}
console.log('hello')
4 changes: 4 additions & 0 deletions test/e2e/app-dir/head/public/hello1.js
@@ -0,0 +1,4 @@
if (typeof window !== 'undefined') {
window.fromHello1 = true
}
console.log('hello1')
4 changes: 4 additions & 0 deletions test/e2e/app-dir/head/public/hello2.js
@@ -0,0 +1,4 @@
if (typeof window !== 'undefined') {
window.fromHello2 = true
}
console.log('hello2')
4 changes: 4 additions & 0 deletions test/e2e/app-dir/head/public/hello3.js
@@ -0,0 +1,4 @@
if (typeof window !== 'undefined') {
window.fromHello3 = true
}
console.log('hello3')