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

Prefetch SSG Data #10127

Merged
merged 48 commits into from Mar 2, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
190a914
Prefetch SSG Data
Timer Jan 16, 2020
d22efaa
Update packages/next/client/page-loader.js
Timer Jan 16, 2020
630f516
Merge branch 'canary' into enhancement/prefetch-ssg-data
Timer Jan 16, 2020
8c1fcef
Merge branch 'canary' into enhancement/prefetch-ssg-data
Timer Feb 16, 2020
eee688c
Revert router.ts
Timer Feb 16, 2020
555b097
Revert link.tsx
Timer Feb 16, 2020
1490cd2
undo change
Timer Feb 16, 2020
5ddf762
Merge branch 'enhancement/prefetch-ssg-data' of github.com:Timer/next…
Timer Feb 16, 2020
98852c4
mimmic existing
Timer Feb 16, 2020
ed7bfc1
simplify
Timer Feb 16, 2020
098391f
Prefetch href and asPath
Timer Feb 16, 2020
2643285
fix load
Timer Feb 16, 2020
6a25c93
dedupe prefetchAs
Timer Feb 16, 2020
825ee1a
Inject script tag on hover
Timer Feb 16, 2020
928717c
Merge branch 'canary' into enhancement/prefetch-ssg-data
Timer Feb 28, 2020
ce085a5
comment prefetchAs
Timer Feb 28, 2020
74ff44c
minify code
Timer Feb 29, 2020
b4585d9
introduce lazy files
Timer Feb 29, 2020
80621c6
Merge branch 'canary' into enhancement/prefetch-ssg-data
Timer Feb 29, 2020
612dfb6
Add some breathing room
Timer Feb 29, 2020
ec5ce44
correct default type
Timer Feb 29, 2020
0595464
Prefetch non-dynamic data
Timer Feb 29, 2020
6e623d8
Prefetch dynamic route data
Timer Mar 1, 2020
00109cb
Fix size test
Timer Mar 1, 2020
c39219d
Humanize code
Timer Mar 1, 2020
ff7486c
Merge branch 'canary' into enhancement/prefetch-ssg-data
Timer Mar 1, 2020
e27d49d
add tests
Timer Mar 1, 2020
3915ace
Merge branch 'canary' into enhancement/prefetch-ssg-data
Timer Mar 1, 2020
24ae003
Merge branch 'canary' into enhancement/prefetch-ssg-data
Timer Mar 1, 2020
103a2ca
Merge branch 'canary' into enhancement/prefetch-ssg-data
Timer Mar 1, 2020
c9e6123
Merge branch 'canary' into enhancement/prefetch-ssg-data
Timer Mar 1, 2020
b082c6d
Merge branch 'canary' into enhancement/prefetch-ssg-data
Timer Mar 1, 2020
2293750
Merge branch 'canary' into enhancement/prefetch-ssg-data
Timer Mar 1, 2020
e4a12ab
Merge branch 'canary' into enhancement/prefetch-ssg-data
Timer Mar 1, 2020
363928b
Disable code
Timer Mar 2, 2020
1c15762
Only generate modern version in modern mode
Timer Mar 2, 2020
0a23d7c
Merge branch 'canary' into enhancement/prefetch-ssg-data
Timer Mar 2, 2020
c0772ce
Merge branch 'canary' into enhancement/prefetch-ssg-data
Timer Mar 2, 2020
fffeb50
Merge branch 'canary' into enhancement/prefetch-ssg-data
Timer Mar 2, 2020
17fb3e4
Extract function helper
Timer Mar 2, 2020
e799bf3
add comments
Timer Mar 2, 2020
3b55b4f
Merge branch 'canary' into enhancement/prefetch-ssg-data
Timer Mar 2, 2020
2ba0b8a
Merge branch 'canary' into enhancement/prefetch-ssg-data
Timer Mar 2, 2020
936bb98
Merge branch 'canary' into enhancement/prefetch-ssg-data
Timer Mar 2, 2020
939e8be
Merge branch 'canary' into enhancement/prefetch-ssg-data
Timer Mar 2, 2020
bd3f208
Merge branch 'canary' into enhancement/prefetch-ssg-data
Timer Mar 2, 2020
c85a3d9
Filter out dynamic route to simplify manifest size
Timer Mar 2, 2020
6ead1b4
add test
Timer Mar 2, 2020
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
42 changes: 42 additions & 0 deletions packages/next/build/index.ts
@@ -1,6 +1,7 @@
import chalk from 'chalk'
import ciEnvironment from 'ci-info'
import crypto from 'crypto'
import devalue from 'devalue'
import escapeStringRegexp from 'escape-string-regexp'
import findUp from 'find-up'
import fs from 'fs'
Expand Down Expand Up @@ -28,6 +29,7 @@ import { recursiveReadDir } from '../lib/recursive-readdir'
import { verifyTypeScriptSetup } from '../lib/verifyTypeScriptSetup'
import {
BUILD_MANIFEST,
CLIENT_STATIC_FILES_PATH,
EXPORT_DETAIL,
EXPORT_MARKER,
PAGES_MANIFEST,
Expand Down Expand Up @@ -851,6 +853,11 @@ export default async function build(dir: string, conf = null): Promise<void> {
JSON.stringify(prerenderManifest),
'utf8'
)
await generateClientSsgManifest(prerenderManifest, {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why can't this be part of the buildManifest?

Copy link
Member Author

@Timer Timer Mar 2, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I intentionally separated them so we didn’t unnecessarily increase the size of either file (since the build manifest blocks navigation).

Also, their generation is different. The build manifest can be computed at build time but the SSG manifest must be post build after we require all the pages in jest-worker.

distDir,
buildId,
isModern: !!config.experimental.modern,
})
} else {
const prerenderManifest: PrerenderManifest = {
version: 2,
Expand All @@ -863,6 +870,8 @@ export default async function build(dir: string, conf = null): Promise<void> {
JSON.stringify(prerenderManifest),
'utf8'
)
// No need to call this fn as we already emitted a default SSG manifest:
// await generateClientSsgManifest(prerenderManifest, { distDir, buildId })
}

await fsWriteFile(
Expand Down Expand Up @@ -961,3 +970,36 @@ export default async function build(dir: string, conf = null): Promise<void> {

await telemetry.flush()
}

function generateClientSsgManifest(
prerenderManifest: PrerenderManifest,
{
buildId,
distDir,
isModern,
}: { buildId: string; distDir: string; isModern: boolean }
) {
const ssgPages: Set<string> = new Set<string>([
...Object.entries(prerenderManifest.routes)
// Filter out dynamic routes
.filter(([, { srcRoute }]) => srcRoute == null)
.map(([route]) => route),
...Object.keys(prerenderManifest.dynamicRoutes),
])

const clientSsgManifestPaths = [
'_ssgManifest.js',
isModern && '_ssgManifest.module.js',
]
.filter(Boolean)
.map(f => path.join(`${CLIENT_STATIC_FILES_PATH}/${buildId}`, f as string))
const clientSsgManifestContent = `self.__SSG_MANIFEST=${devalue(
ssgPages
)};self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()`
clientSsgManifestPaths.forEach(clientSsgManifestPath =>
fs.writeFileSync(
path.join(distDir, clientSsgManifestPath),
clientSsgManifestContent
)
)
}
17 changes: 17 additions & 0 deletions packages/next/build/webpack/plugins/build-manifest-plugin.ts
Expand Up @@ -147,6 +147,23 @@ export default class BuildManifestPlugin {
}
}

// Add the runtime ssg manifest file as a lazy-loaded file dependency.
// We also stub this file out for development mode (when it is not
// generated).
const srcEmptySsgManifest = `self.__SSG_MANIFEST=new Set;self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()`

const ssgManifestPath = `${CLIENT_STATIC_FILES_PATH}/${this.buildId}/_ssgManifest.js`
assetMap.lowPriorityFiles.push(ssgManifestPath)
compilation.assets[ssgManifestPath] = new RawSource(srcEmptySsgManifest)

if (this.modern) {
const ssgManifestPathModern = `${CLIENT_STATIC_FILES_PATH}/${this.buildId}/_ssgManifest.module.js`
assetMap.lowPriorityFiles.push(ssgManifestPathModern)
compilation.assets[ssgManifestPathModern] = new RawSource(
srcEmptySsgManifest
)
}

assetMap.pages = Object.keys(assetMap.pages)
.sort()
// eslint-disable-next-line
Expand Down
103 changes: 103 additions & 0 deletions packages/next/client/page-loader.js
@@ -1,4 +1,8 @@
import { parse } from 'url'
import mitt from '../next-server/lib/mitt'
import { isDynamicRoute } from './../next-server/lib/router/utils/is-dynamic'
import { getRouteMatcher } from './../next-server/lib/router/utils/route-matcher'
import { getRouteRegex } from './../next-server/lib/router/utils/route-regex'

function hasRel(rel, link) {
try {
Expand All @@ -18,6 +22,7 @@ const relPrefetch =

const hasNoModule = 'noModule' in document.createElement('script')

/** @param {string} route */
function normalizeRoute(route) {
if (route[0] !== '/') {
throw new Error(`Route name should start with a "/", got "${route}"`)
Expand Down Expand Up @@ -62,6 +67,16 @@ export default class PageLoader {
}
})
}
/** @type {Promise<Set<string>>} */
this.promisedSsgManifest = new Promise(resolve => {
if (window.__SSG_MANIFEST) {
resolve(window.__SSG_MANIFEST)
} else {
window.__SSG_MANIFEST_CB = () => {
resolve(window.__SSG_MANIFEST)
}
}
})
}

// Returns a promise for the dependencies for a particular route
Expand All @@ -76,6 +91,89 @@ export default class PageLoader {
)
}

/**
* @param {string} href the route href (file-system path)
* @param {string} asPath the URL as shown in browser (virtual path); used for dynamic routes
*/
getDataHref(href, asPath) {
const getHrefForSlug = (/** @type string */ path) =>
`${this.assetPrefix}/_next/data/${this.buildId}${
path === '/' ? '/index' : path
}.json`

const { pathname: hrefPathname, query } = parse(href, true)
const { pathname: asPathname } = parse(asPath)

const route = normalizeRoute(hrefPathname)

let isDynamic = isDynamicRoute(route),
interpolatedRoute
if (isDynamic) {
const dynamicRegex = getRouteRegex(route)
const dynamicGroups = dynamicRegex.groups
const dynamicMatches =
// Try to match the dynamic route against the asPath
getRouteMatcher(dynamicRegex)(asPathname) ||
// Fall back to reading the values from the href
// TODO: should this take priority; also need to change in the router.
query

interpolatedRoute = route
if (
!Object.keys(dynamicGroups).every(param => {
let value = dynamicMatches[param]
const repeat = dynamicGroups[param].repeat

// support single-level catch-all
// TODO: more robust handling for user-error (passing `/`)
if (repeat && !Array.isArray(value)) value = [value]

return (
param in dynamicMatches &&
// Interpolate group into data URL if present
(interpolatedRoute = interpolatedRoute.replace(
`[${repeat ? '...' : ''}${param}]`,
repeat
? value.map(encodeURIComponent).join('/')
: encodeURIComponent(value)
))
)
})
) {
interpolatedRoute = '' // did not satisfy all requirements

// n.b. We ignore this error because we handle warning for this case in
// development in the `<Link>` component directly.
}
}

return isDynamic
? interpolatedRoute && getHrefForSlug(interpolatedRoute)
: getHrefForSlug(route)
}

/**
* @param {string} href the route href (file-system path)
* @param {string} asPath the URL as shown in browser (virtual path); used for dynamic routes
*/
prefetchData(href, asPath) {
const { pathname: hrefPathname } = parse(href, true)
const route = normalizeRoute(hrefPathname)
return this.promisedSsgManifest.then(
(s, _dataHref) =>
// Check if the route requires a data file
s.has(route) &&
// Try to generate data href, noop when falsy
(_dataHref = this.getDataHref(href, asPath)) &&
// noop when data has already been prefetched (dedupe)
!document.querySelector(
`link[rel="${relPrefetch}"][href^="${_dataHref}"]`
) &&
// Inject the `<link rel=prefetch>` tag for above computed `href`.
appendLink(_dataHref, relPrefetch, 'fetch')
)
}

loadPage(route) {
return this.loadPageScript(route).then(v => v.page)
}
Expand Down Expand Up @@ -206,6 +304,10 @@ export default class PageLoader {
register()
}

/**
* @param {string} route
* @param {boolean} [isDependency]
*/
prefetch(route, isDependency) {
// https://github.com/GoogleChromeLabs/quicklink/blob/453a661fa1fa940e2d2e044452398e38c67a98fb/src/index.mjs#L115-L118
// License: Apache 2.0
Expand All @@ -215,6 +317,7 @@ export default class PageLoader {
if (cn.saveData || /2g/.test(cn.effectiveType)) return Promise.resolve()
}

/** @type {string} */
let url
if (isDependency) {
url = route
Expand Down
9 changes: 6 additions & 3 deletions packages/next/next-server/lib/router/router.ts
Expand Up @@ -703,9 +703,12 @@ export default class Router implements BaseRouter {
return
}

this.pageLoader[options.priority ? 'loadPage' : 'prefetch'](
toRoute(pathname)
).then(() => resolve(), reject)
Promise.all([
this.pageLoader.prefetchData(url, asPath),
this.pageLoader[options.priority ? 'loadPage' : 'prefetch'](
toRoute(pathname)
),
]).then(() => resolve(), reject)
})
}

Expand Down
5 changes: 5 additions & 0 deletions test/integration/preload-viewport/next.config.js
@@ -0,0 +1,5 @@
module.exports = {
generateBuildId() {
return 'test-build'
},
}
5 changes: 5 additions & 0 deletions test/integration/preload-viewport/pages/ssg/basic.js
@@ -0,0 +1,5 @@
export function getStaticProps() {
return { props: { message: 'hello world' } }
}

export default ({ message }) => <p id="content">{message}</p>
@@ -0,0 +1,12 @@
export function getStaticProps({ params }) {
return { props: { message: `hello ${params.slug.join(' ')}` } }
}

export function getStaticPaths() {
return {
paths: ['/ssg/catch-all/one', '/ssg/catch-all/one/two'],
fallback: true,
}
}

export default ({ message }) => <p id="content">{message || 'loading'}</p>
@@ -0,0 +1,12 @@
export function getStaticProps({ params }) {
return { props: { message: `hello ${params.slug1} ${params.slug2}` } }
}

export function getStaticPaths() {
return {
paths: ['/ssg/dynamic-nested/one/two'],
fallback: true,
}
}

export default ({ message }) => <p id="content">{message || 'loading'}</p>
@@ -0,0 +1,9 @@
export function getStaticProps({ params }) {
return { props: { message: `hello ${params.slug}` } }
}

export function getStaticPaths() {
return { paths: ['/ssg/dynamic/one'], fallback: true }
}

export default ({ message }) => <p id="content">{message || 'loading'}</p>
65 changes: 65 additions & 0 deletions test/integration/preload-viewport/pages/ssg/fixture/index.js
@@ -0,0 +1,65 @@
import Link from 'next/link'

export default () => (
<main>
<h1>SSG Data Prefetch Fixtures</h1>
<p>
<Link href="/ssg/basic">
<a>Non-dynamic route</a>
</Link>
: this is a normal Next.js page that does not use dynamic routing.
</p>
<p>
<Link href="/ssg/dynamic/[slug]" as="/ssg/dynamic/one">
<a>Dynamic Route (one level) — Prerendered</a>
</Link>
: this is a Dynamic Page with a single dynamic segment that{' '}
<strong>was returned</strong> from <code>getStaticPaths</code>.<br />
<Link href="/ssg/dynamic/[slug]" as="/ssg/dynamic/two">
<a>Dynamic Route (one level) — Not Prerendered</a>
</Link>
: this is a Dynamic Page with a single dynamic segment that{' '}
<strong>was not returned</strong> from <code>getStaticPaths</code>.
</p>
<p>
<Link
href="/ssg/dynamic-nested/[slug1]/[slug2]"
as="/ssg/dynamic-nested/one/two"
>
<a>Multi Dynamic Route (two levels) — Prerendered</a>
</Link>
: this is a Dynamic Page with two dynamic segments that{' '}
<strong>were returned</strong> from <code>getStaticPaths</code>.<br />
<Link
href="/ssg/dynamic-nested/[slug1]/[slug2]"
as="/ssg/dynamic-nested/foo/bar"
>
<a>Multi Dynamic Route (two levels) — Not Prerendered</a>
</Link>
: this is a Dynamic Page with two dynamic segments that{' '}
<strong>were not returned</strong> from <code>getStaticPaths</code>.
</p>
<p>
<Link href="/ssg/catch-all/[...slug]" as="/ssg/catch-all/one">
<a>Catch-All Route (one level) — Prerendered</a>
</Link>
: this is a Catch-All Page with one segment that{' '}
<strong>was returned</strong> from <code>getStaticPaths</code>.<br />
<Link href="/ssg/catch-all/[...slug]" as="/ssg/catch-all/foo">
<a>Catch-All Route (one level) — Not Prerendered</a>
</Link>
: this is a Catch-All Page with one segment that{' '}
<strong>was not returned</strong> from <code>getStaticPaths</code>.<br />
<Link href="/ssg/catch-all/[...slug]" as="/ssg/catch-all/one/two">
<a>Catch-All Route (two levels) — Prerendered</a>
</Link>
: this is a Catch-All Page with two segments that{' '}
<strong>were returned</strong> from <code>getStaticPaths</code>.<br />
<Link href="/ssg/catch-all/[...slug]" as="/ssg/catch-all/foo/bar">
<a>Catch-All Route (two levels) — Not Prerendered</a>
</Link>
: this is a Catch-All Page with two segments that{' '}
<strong>were not returned</strong> from <code>getStaticPaths</code>.
</p>
</main>
)