Skip to content

Commit

Permalink
Prefetch SSG Data (#10127)
Browse files Browse the repository at this point in the history
* Prefetch SSG Data

* Update packages/next/client/page-loader.js

Co-Authored-By: JJ Kasper <jj@jjsweb.site>

* Revert router.ts

* Revert link.tsx

* undo change

* mimmic existing

* simplify

* Prefetch href and asPath

* fix load

* dedupe prefetchAs

* Inject script tag on hover

* comment prefetchAs

* minify code

* introduce lazy files

* Add some breathing room

* correct default type

* Prefetch non-dynamic data

* Prefetch dynamic route data

* Fix size test

* Humanize code

* add tests

* Disable code

* Only generate modern version in modern mode

* Extract function helper

* add comments

* Filter out dynamic route to simplify manifest size

* add test

Co-authored-by: JJ Kasper <jj@jjsweb.site>
  • Loading branch information
Timer and ijjk committed Mar 2, 2020
1 parent 8f01a4a commit 990eda2
Show file tree
Hide file tree
Showing 13 changed files with 443 additions and 5 deletions.
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, {
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>
12 changes: 12 additions & 0 deletions test/integration/preload-viewport/pages/ssg/catch-all/[...slug].js
@@ -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>
9 changes: 9 additions & 0 deletions test/integration/preload-viewport/pages/ssg/dynamic/[slug].js
@@ -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>
)

0 comments on commit 990eda2

Please sign in to comment.