Skip to content

Commit

Permalink
Add initial head handling in app (#41607)
Browse files Browse the repository at this point in the history
<!--
Thanks for opening a PR! Your contribution is much appreciated.
To make sure your PR is handled as smoothly as possible we request that
you follow the checklist sections below.
Choose the right checklist for the change that you're making:
-->

## Bug

- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added
- [ ] Errors have a helpful link attached, see `contributing.md`

## Feature

- [ ] Implements an existing feature request or RFC. Make sure the
feature request has been accepted for implementation before opening a
PR.
- [ ] Related issues linked using `fixes #number`
- [ ] Integration tests added
- [ ] Documentation added
- [ ] Telemetry added. In case of a feature if it's used or not.
- [ ] Errors have a helpful link attached, see `contributing.md`

## Documentation / Examples

- [ ] Make sure the linting passes by running `pnpm lint`
- [ ] The "examples guidelines" are followed from [our contributing
doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md)
  • Loading branch information
ijjk committed Oct 25, 2022
1 parent e8d5de6 commit 67c802a
Show file tree
Hide file tree
Showing 19 changed files with 273 additions and 1 deletion.
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')

0 comments on commit 67c802a

Please sign in to comment.