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 initial head handling in app #41607

Merged
merged 10 commits into from Oct 25, 2022
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
28 changes: 28 additions & 0 deletions packages/next/server/app-render.tsx
Expand Up @@ -912,19 +912,27 @@ export async function renderToHTMLOrFlight(
template,
error,
loading,
head,
page,
'not-found': notFound,
},
],
parentParams,
firstItem,
rootLayoutIncluded,
collectedHeads = [],
}: {
createSegmentPath: CreateSegmentPath
loaderTree: LoaderTree
parentParams: { [key: string]: any }
rootLayoutIncluded?: boolean
firstItem?: boolean
collectedHeads?: Array<
(ctx: {
params?: Record<string, string | string[]>
searchParams?: Record<string, string | string[]>
}) => Promise<React.ElementType>
>
}): Promise<{ Component: React.ComponentType }> => {
// TODO-APP: enable stylesheet per layout/page
const stylesheets: string[] = layoutOrPagePath
Expand All @@ -948,6 +956,7 @@ export async function renderToHTMLOrFlight(
: React.Fragment
const ErrorComponent = error ? await interopDefault(error()) : undefined
const Loading = loading ? await interopDefault(loading()) : undefined
const Head = head ? await interopDefault(head()) : undefined
const isLayout = typeof layout !== 'undefined'
const isPage = typeof page !== 'undefined'
const layoutOrPageMod = isLayout
Expand Down Expand Up @@ -1053,6 +1062,17 @@ export async function renderToHTMLOrFlight(
// Resolve the segment param
const actualSegment = segmentParam ? segmentParam.treeSegment : segment

// collect head pieces
if (typeof Head === 'function') {
collectedHeads.push(() =>
Head({
params: currentParams,
// TODO-APP: allow searchParams?
// ...(isPage ? { searchParams: query } : {}),
})
)
}

// This happens outside of rendering in order to eagerly kick off data fetching for layouts / the page further down
const parallelRouteMap = await Promise.all(
Object.keys(parallelRoutes).map(
Expand Down Expand Up @@ -1102,6 +1122,7 @@ export async function renderToHTMLOrFlight(
loaderTree: parallelRoutes[parallelRouteKey],
parentParams: currentParams,
rootLayoutIncluded: rootLayoutIncludedAtThisLevelOrAbove,
collectedHeads,
})

const childProp: ChildProp = {
Expand Down Expand Up @@ -1160,6 +1181,12 @@ export async function renderToHTMLOrFlight(
// Add extra cache busting (DEV only) for https://github.com/vercel/next.js/issues/5860
// See also https://bugs.webkit.org/show_bug.cgi?id=187726
const cacheBustingUrlSuffix = dev ? `?ts=${Date.now()}` : ''
let HeadTags
if (rootLayoutAtThisLevel) {
// TODO: iterate HeadTag children and add a data-path attribute
// so that we can remove elements on client-transition
HeadTags = collectedHeads[collectedHeads.length - 1] as any
}

return (
<>
Expand Down Expand Up @@ -1200,6 +1227,7 @@ export async function renderToHTMLOrFlight(
// Query is only provided to page
{...(isPage ? { searchParams: query } : {})}
/>
{HeadTags ? <HeadTags /> : null}
</>
)
},
Expand Down
3 changes: 2 additions & 1 deletion test/e2e/app-dir/back-button-download-bug.test.ts
Expand Up @@ -3,7 +3,8 @@ import { NextInstance } from 'test/lib/next-modes/base'
import path from 'path'
import webdriver from 'next-webdriver'

describe('app-dir back button download bug', () => {
// TODO-APP: fix test as it's failing randomly
describe.skip('app-dir back button download bug', () => {
if ((global as any).isNextDeploy) {
it('should skip next deploy for now', () => {})
return
Expand Down
97 changes: 97 additions & 0 deletions test/e2e/app-dir/head.test.ts
@@ -0,0 +1,97 @@
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 use correct head for /blog', 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')).toBeFalsy()
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 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}
</>
)
}
13 changes: 13 additions & 0 deletions test/e2e/app-dir/head/app/blog/page.js
@@ -0,0 +1,13 @@
import Link from 'next/link'

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