From 9300151118ea891eb554e634b4954210fb5a7333 Mon Sep 17 00:00:00 2001 From: Jan Potoms <2109932+Janpot@users.noreply.github.com> Date: Wed, 14 Oct 2020 11:55:42 +0200 Subject: [PATCH] Allow pages to be async modules to enable top-level-await (#17590) Co-authored-by: JJ Kasper Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com> --- .eslintignore | 3 +- .github/workflows/build_test_deploy.yml | 1 + packages/next/build/index.ts | 2 +- packages/next/build/utils.ts | 25 +-- .../webpack/loaders/next-serverless-loader.ts | 50 ++++-- packages/next/client/page-loader.ts | 4 +- .../next-server/server/load-components.ts | 17 +- .../next/next-server/server/next-server.ts | 2 +- test/integration/async-modules/next.config.js | 9 ++ test/integration/async-modules/pages/404.jsx | 5 + test/integration/async-modules/pages/_app.jsx | 5 + .../async-modules/pages/_document.jsx | 25 +++ .../async-modules/pages/_error.jsx | 7 + .../async-modules/pages/api/hello.js | 5 + .../async-modules/pages/config.jsx | 22 +++ test/integration/async-modules/pages/gsp.jsx | 15 ++ test/integration/async-modules/pages/gssp.jsx | 15 ++ .../integration/async-modules/pages/index.jsx | 10 ++ .../async-modules/pages/make-error.jsx | 7 + .../async-modules/test/index.test.js | 146 ++++++++++++++++++ 20 files changed, 339 insertions(+), 36 deletions(-) create mode 100644 test/integration/async-modules/next.config.js create mode 100644 test/integration/async-modules/pages/404.jsx create mode 100644 test/integration/async-modules/pages/_app.jsx create mode 100644 test/integration/async-modules/pages/_document.jsx create mode 100644 test/integration/async-modules/pages/_error.jsx create mode 100644 test/integration/async-modules/pages/api/hello.js create mode 100644 test/integration/async-modules/pages/config.jsx create mode 100644 test/integration/async-modules/pages/gsp.jsx create mode 100644 test/integration/async-modules/pages/gssp.jsx create mode 100644 test/integration/async-modules/pages/index.jsx create mode 100644 test/integration/async-modules/pages/make-error.jsx create mode 100644 test/integration/async-modules/test/index.test.js diff --git a/.eslintignore b/.eslintignore index e58e7db214..48f9e69a57 100644 --- a/.eslintignore +++ b/.eslintignore @@ -13,4 +13,5 @@ packages/next-codemod/transforms/__testfixtures__/**/* packages/next-codemod/transforms/__tests__/**/* packages/next-codemod/**/*.js packages/next-codemod/**/*.d.ts -packages/next-env/**/*.d.ts \ No newline at end of file +packages/next-env/**/*.d.ts +test/integration/async-modules/** diff --git a/.github/workflows/build_test_deploy.yml b/.github/workflows/build_test_deploy.yml index 5bc2f97e2b..884eaaf91c 100644 --- a/.github/workflows/build_test_deploy.yml +++ b/.github/workflows/build_test_deploy.yml @@ -113,6 +113,7 @@ jobs: - run: yarn install --check-files - run: node run-tests.js test/integration/production/test/index.test.js - run: node run-tests.js test/integration/basic/test/index.test.js + - run: node run-tests.js test/integration/async-modules/test/index.test.js - run: node run-tests.js test/integration/font-optimization/test/index.test.js - run: node run-tests.js test/acceptance/* diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index 1ddb4f3d3c..0633784c85 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -531,7 +531,7 @@ export default async function build( const serverBundle = getPagePath(page, distDir, isLikeServerless) if (customAppGetInitialProps === undefined) { - customAppGetInitialProps = hasCustomGetInitialProps( + customAppGetInitialProps = await hasCustomGetInitialProps( isLikeServerless ? serverBundle : getPagePath('/_app', distDir, isLikeServerless), diff --git a/packages/next/build/utils.ts b/packages/next/build/utils.ts index be53e423c8..7e1eb11cc5 100644 --- a/packages/next/build/utils.ts +++ b/packages/next/build/utils.ts @@ -704,21 +704,21 @@ export async function isPageStatic( }> { try { require('../next-server/lib/runtime-config').setConfig(runtimeEnvConfig) - const mod = require(serverBundle) - const Comp = mod.default || mod + const mod = await require(serverBundle) + const Comp = await (mod.default || mod) if (!Comp || !isValidElementType(Comp) || typeof Comp === 'string') { throw new Error('INVALID_DEFAULT_EXPORT') } const hasGetInitialProps = !!(Comp as any).getInitialProps - const hasStaticProps = !!mod.getStaticProps - const hasStaticPaths = !!mod.getStaticPaths - const hasServerProps = !!mod.getServerSideProps - const hasLegacyServerProps = !!mod.unstable_getServerProps - const hasLegacyStaticProps = !!mod.unstable_getStaticProps - const hasLegacyStaticPaths = !!mod.unstable_getStaticPaths - const hasLegacyStaticParams = !!mod.unstable_getStaticParams + const hasStaticProps = !!(await mod.getStaticProps) + const hasStaticPaths = !!(await mod.getStaticPaths) + const hasServerProps = !!(await mod.getServerSideProps) + const hasLegacyServerProps = !!(await mod.unstable_getServerProps) + const hasLegacyStaticProps = !!(await mod.unstable_getStaticProps) + const hasLegacyStaticPaths = !!(await mod.unstable_getStaticPaths) + const hasLegacyStaticParams = !!(await mod.unstable_getStaticParams) if (hasLegacyStaticParams) { throw new Error( @@ -804,19 +804,20 @@ export async function isPageStatic( } } -export function hasCustomGetInitialProps( +export async function hasCustomGetInitialProps( bundle: string, runtimeEnvConfig: any, checkingApp: boolean -): boolean { +): Promise { require('../next-server/lib/runtime-config').setConfig(runtimeEnvConfig) let mod = require(bundle) if (checkingApp) { - mod = mod._app || mod.default || mod + mod = (await mod._app) || mod.default || mod } else { mod = mod.default || mod } + mod = await mod return mod.getInitialProps !== mod.origGetInitialProps } diff --git a/packages/next/build/webpack/loaders/next-serverless-loader.ts b/packages/next/build/webpack/loaders/next-serverless-loader.ts index 6d669310f7..f437b85ce3 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader.ts @@ -339,7 +339,7 @@ const nextServerlessLoader: loader.Loader = function () { : `{}` } - const resolver = require('${absolutePagePath}') + const resolver = await require('${absolutePagePath}') await apiResolver( req, res, @@ -386,35 +386,57 @@ const nextServerlessLoader: loader.Loader = function () { const {sendPayload} = require('next/dist/next-server/server/send-payload'); const buildManifest = require('${buildManifest}'); const reactLoadableManifest = require('${reactLoadableManifest}'); - const Document = require('${absoluteDocumentPath}').default; - const Error = require('${absoluteErrorPath}').default; - const App = require('${absoluteAppPath}').default; + + const appMod = require('${absoluteAppPath}') + let App = appMod.default || appMod.then && appMod.then(mod => mod.default); ${dynamicRouteImports} ${rewriteImports} - const ComponentInfo = require('${absolutePagePath}') + const compMod = require('${absolutePagePath}') - const Component = ComponentInfo.default + let Component = compMod.default || compMod.then && compMod.then(mod => mod.default) export default Component - export const unstable_getStaticParams = ComponentInfo['unstable_getStaticParam' + 's'] - export const getStaticProps = ComponentInfo['getStaticProp' + 's'] - export const getStaticPaths = ComponentInfo['getStaticPath' + 's'] - export const getServerSideProps = ComponentInfo['getServerSideProp' + 's'] + export let getStaticProps = compMod['getStaticProp' + 's'] || compMod.then && compMod.then(mod => mod['getStaticProp' + 's']) + export let getStaticPaths = compMod['getStaticPath' + 's'] || compMod.then && compMod.then(mod => mod['getStaticPath' + 's']) + export let getServerSideProps = compMod['getServerSideProp' + 's'] || compMod.then && compMod.then(mod => mod['getServerSideProp' + 's']) // kept for detecting legacy exports - export const unstable_getStaticProps = ComponentInfo['unstable_getStaticProp' + 's'] - export const unstable_getStaticPaths = ComponentInfo['unstable_getStaticPath' + 's'] - export const unstable_getServerProps = ComponentInfo['unstable_getServerProp' + 's'] + export const unstable_getStaticParams = compMod['unstable_getStaticParam' + 's'] || compMod.then && compMod.then(mod => mod['unstable_getStaticParam' + 's']) + export const unstable_getStaticProps = compMod['unstable_getStaticProp' + 's'] || compMod.then && compMod.then(mod => mod['unstable_getStaticProp' + 's']) + export const unstable_getStaticPaths = compMod['unstable_getStaticPath' + 's'] || compMod.then && compMod.then(mod => mod['unstable_getStaticPath' + 's']) + export const unstable_getServerProps = compMod['unstable_getServerProp' + 's'] || compMod.then && compMod.then(mod => mod['unstable_getServerProp' + 's']) ${dynamicRouteMatcher} ${defaultRouteRegex} ${normalizeDynamicRouteParams} ${handleRewrites} - export const config = ComponentInfo['confi' + 'g'] || {} + export let config = compMod['confi' + 'g'] || (compMod.then && compMod.then(mod => mod['confi' + 'g'])) || {} export const _app = App export async function renderReqToHTML(req, res, renderMode, _renderOpts, _params) { + let Document + let Error + ;[ + getStaticProps, + getServerSideProps, + getStaticPaths, + Component, + App, + config, + { default: Document }, + { default: Error } + ] = await Promise.all([ + getStaticProps, + getServerSideProps, + getStaticPaths, + Component, + App, + config, + require('${absoluteDocumentPath}'), + require('${absoluteErrorPath}') + ]) + const fromExport = renderMode === 'export' || renderMode === true; const nextStartMode = renderMode === 'passthrough' diff --git a/packages/next/client/page-loader.ts b/packages/next/client/page-loader.ts index e7a339dc12..58d6ccf647 100644 --- a/packages/next/client/page-loader.ts +++ b/packages/next/client/page-loader.ts @@ -340,9 +340,9 @@ export default class PageLoader { // This method if called by the route code. registerPage(route: string, regFn: () => any) { - const register = (styleSheets: StyleSheetTuple[]) => { + const register = async (styleSheets: StyleSheetTuple[]) => { try { - const mod = regFn() + const mod = await regFn() const pageData: PageCacheEntry = { page: mod.default || mod, mod, diff --git a/packages/next/next-server/server/load-components.ts b/packages/next/next-server/server/load-components.ts index c523f0a98b..4eac4dce06 100644 --- a/packages/next/next-server/server/load-components.ts +++ b/packages/next/next-server/server/load-components.ts @@ -41,20 +41,27 @@ export async function loadComponents( ): Promise { if (serverless) { const Component = await requirePage(pathname, distDir, serverless) - const { getStaticProps, getStaticPaths, getServerSideProps } = Component + let { getStaticProps, getStaticPaths, getServerSideProps } = Component + + getStaticProps = await getStaticProps + getStaticPaths = await getStaticPaths + getServerSideProps = await getServerSideProps + const pageConfig = (await Component.config) || {} return { Component, - pageConfig: Component.config || {}, + pageConfig, getStaticProps, getStaticPaths, getServerSideProps, } as LoadComponentsReturnType } - const DocumentMod = requirePage('/_document', distDir, serverless) - const AppMod = requirePage('/_app', distDir, serverless) - const ComponentMod = requirePage(pathname, distDir, serverless) + const [DocumentMod, AppMod, ComponentMod] = await Promise.all([ + requirePage('/_document', distDir, serverless), + requirePage('/_app', distDir, serverless), + requirePage(pathname, distDir, serverless), + ]) const [ buildManifest, diff --git a/packages/next/next-server/server/next-server.ts b/packages/next/next-server/server/next-server.ts index 69a4b75f00..8fbc333303 100644 --- a/packages/next/next-server/server/next-server.ts +++ b/packages/next/next-server/server/next-server.ts @@ -866,7 +866,7 @@ export default class Server { throw err } - const pageModule = require(builtPagePath) + const pageModule = await require(builtPagePath) query = { ...query, ...params } if (!this.renderOpts.dev && this._isLikeServerless) { diff --git a/test/integration/async-modules/next.config.js b/test/integration/async-modules/next.config.js new file mode 100644 index 0000000000..012728c1ee --- /dev/null +++ b/test/integration/async-modules/next.config.js @@ -0,0 +1,9 @@ +module.exports = { + // target: 'experimental-serverless-trace', + webpack: (config, options) => { + config.experiments = { + topLevelAwait: true, + } + return config + }, +} diff --git a/test/integration/async-modules/pages/404.jsx b/test/integration/async-modules/pages/404.jsx new file mode 100644 index 0000000000..42176bf46e --- /dev/null +++ b/test/integration/async-modules/pages/404.jsx @@ -0,0 +1,5 @@ +const content = await Promise.resolve("hi y'all") + +export default function Custom404() { + return

{content}

+} diff --git a/test/integration/async-modules/pages/_app.jsx b/test/integration/async-modules/pages/_app.jsx new file mode 100644 index 0000000000..1c933d2043 --- /dev/null +++ b/test/integration/async-modules/pages/_app.jsx @@ -0,0 +1,5 @@ +const appValue = await Promise.resolve('hello') + +export default function MyApp({ Component, pageProps }) { + return +} diff --git a/test/integration/async-modules/pages/_document.jsx b/test/integration/async-modules/pages/_document.jsx new file mode 100644 index 0000000000..fa5ac2ac1a --- /dev/null +++ b/test/integration/async-modules/pages/_document.jsx @@ -0,0 +1,25 @@ +import Document, { Html, Head, Main, NextScript } from 'next/document' + +const docValue = await Promise.resolve('doc value') + +class MyDocument extends Document { + static async getInitialProps(ctx) { + const initialProps = await Document.getInitialProps(ctx) + return { ...initialProps, docValue } + } + + render() { + return ( + + + +
{this.props.docValue}
+
+ + + + ) + } +} + +export default MyDocument diff --git a/test/integration/async-modules/pages/_error.jsx b/test/integration/async-modules/pages/_error.jsx new file mode 100644 index 0000000000..521df7a7bd --- /dev/null +++ b/test/integration/async-modules/pages/_error.jsx @@ -0,0 +1,7 @@ +const errorContent = await Promise.resolve('hello error') + +function Error({ statusCode }) { + return

{errorContent}

+} + +export default Error diff --git a/test/integration/async-modules/pages/api/hello.js b/test/integration/async-modules/pages/api/hello.js new file mode 100644 index 0000000000..9fe171b1fc --- /dev/null +++ b/test/integration/async-modules/pages/api/hello.js @@ -0,0 +1,5 @@ +const value = await Promise.resolve(42) + +export default function (req, res) { + res.json({ value }) +} diff --git a/test/integration/async-modules/pages/config.jsx b/test/integration/async-modules/pages/config.jsx new file mode 100644 index 0000000000..ff9aa6ea34 --- /dev/null +++ b/test/integration/async-modules/pages/config.jsx @@ -0,0 +1,22 @@ +export const config = { + amp: true, +} + +await Promise.resolve('tadaa') + +export default function Config() { + const date = new Date() + return ( +
+ + fail + +
+ ) +} diff --git a/test/integration/async-modules/pages/gsp.jsx b/test/integration/async-modules/pages/gsp.jsx new file mode 100644 index 0000000000..072f2cfafe --- /dev/null +++ b/test/integration/async-modules/pages/gsp.jsx @@ -0,0 +1,15 @@ +const gspValue = await Promise.resolve(42) + +export async function getStaticProps() { + return { + props: { gspValue }, + } +} + +export default function Index({ gspValue }) { + return ( +
+
{gspValue}
+
+ ) +} diff --git a/test/integration/async-modules/pages/gssp.jsx b/test/integration/async-modules/pages/gssp.jsx new file mode 100644 index 0000000000..33775bf3a9 --- /dev/null +++ b/test/integration/async-modules/pages/gssp.jsx @@ -0,0 +1,15 @@ +const gsspValue = await Promise.resolve(42) + +export async function getServerSideProps() { + return { + props: { gsspValue }, + } +} + +export default function Index({ gsspValue }) { + return ( +
+
{gsspValue}
+
+ ) +} diff --git a/test/integration/async-modules/pages/index.jsx b/test/integration/async-modules/pages/index.jsx new file mode 100644 index 0000000000..df079ba0f3 --- /dev/null +++ b/test/integration/async-modules/pages/index.jsx @@ -0,0 +1,10 @@ +const value = await Promise.resolve(42) + +export default function Index({ appValue }) { + return ( +
+
{appValue}
+
{value}
+
+ ) +} diff --git a/test/integration/async-modules/pages/make-error.jsx b/test/integration/async-modules/pages/make-error.jsx new file mode 100644 index 0000000000..bd44466d28 --- /dev/null +++ b/test/integration/async-modules/pages/make-error.jsx @@ -0,0 +1,7 @@ +export async function getServerSideProps() { + throw new Error('BOOM') +} + +export default function Page() { + return
hello
+} diff --git a/test/integration/async-modules/test/index.test.js b/test/integration/async-modules/test/index.test.js new file mode 100644 index 0000000000..2e67152943 --- /dev/null +++ b/test/integration/async-modules/test/index.test.js @@ -0,0 +1,146 @@ +/* eslint-env jest */ + +import webdriver from 'next-webdriver' + +import cheerio from 'cheerio' +import { + fetchViaHTTP, + renderViaHTTP, + findPort, + killApp, + launchApp, + nextBuild, + nextStart, + File, +} from 'next-test-utils' +import { join } from 'path' +import webpack from 'webpack' + +jest.setTimeout(1000 * 60 * 2) + +const isWebpack5 = parseInt(webpack.version) === 5 +let app +let appPort +const appDir = join(__dirname, '../') +const nextConfig = new File(join(appDir, 'next.config.js')) + +function runTests(dev = false) { + it('ssr async page modules', async () => { + const html = await renderViaHTTP(appPort, '/') + const $ = cheerio.load(html) + expect($('#app-value').text()).toBe('hello') + expect($('#page-value').text()).toBe('42') + }) + + it('csr async page modules', async () => { + let browser + try { + browser = await webdriver(appPort, '/') + expect(await browser.elementByCss('#app-value').text()).toBe('hello') + expect(await browser.elementByCss('#page-value').text()).toBe('42') + expect(await browser.elementByCss('#doc-value').text()).toBe('doc value') + } finally { + if (browser) await browser.close() + } + }) + + it('works on async api routes', async () => { + const res = await fetchViaHTTP(appPort, '/api/hello') + expect(res.status).toBe(200) + const result = await res.json() + expect(result).toHaveProperty('value', 42) + }) + + it('works with getServerSideProps', async () => { + let browser + try { + browser = await webdriver(appPort, '/gssp') + expect(await browser.elementByCss('#gssp-value').text()).toBe('42') + } finally { + if (browser) await browser.close() + } + }) + + it('works with getStaticProps', async () => { + let browser + try { + browser = await webdriver(appPort, '/gsp') + expect(await browser.elementByCss('#gsp-value').text()).toBe('42') + } finally { + if (browser) await browser.close() + } + }) + + it('can render async 404 pages', async () => { + let browser + try { + browser = await webdriver(appPort, '/dhiuhefoiahjeoij') + expect(await browser.elementByCss('#content-404').text()).toBe("hi y'all") + } finally { + if (browser) await browser.close() + } + }) + + it('can render async AMP pages', async () => { + let browser + try { + browser = await webdriver(appPort, '/config') + expect(await browser.elementByCss('#amp-timeago').text()).not.toBe('fail') + } finally { + if (browser) await browser.close() + } + }) + ;(dev ? it.skip : it)('can render async error page', async () => { + let browser + try { + browser = await webdriver(appPort, '/make-error') + expect(await browser.elementByCss('#content-error').text()).toBe( + 'hello error' + ) + } finally { + if (browser) await browser.close() + } + }) +} + +;(isWebpack5 ? describe : describe.skip)('Async modules', () => { + describe('dev mode', () => { + beforeAll(async () => { + appPort = await findPort() + app = await launchApp(appDir, appPort) + }) + afterAll(async () => { + await killApp(app) + }) + + runTests(true) + }) + + describe('production mode', () => { + beforeAll(async () => { + await nextBuild(appDir) + appPort = await findPort() + app = await nextStart(appDir, appPort) + }) + afterAll(async () => { + await killApp(app) + }) + + runTests() + }) + + describe('serverless mode', () => { + beforeAll(async () => { + nextConfig.replace('// target:', 'target:') + await nextBuild(appDir) + appPort = await findPort() + app = await nextStart(appDir, appPort) + }) + afterAll(async () => { + await nextConfig.restore() + await killApp(app) + }) + + runTests() + }) +})