diff --git a/.github/workflows/build_test_deploy.yml b/.github/workflows/build_test_deploy.yml index 2fb9ad7943345..51d848de12dc9 100644 --- a/.github/workflows/build_test_deploy.yml +++ b/.github/workflows/build_test_deploy.yml @@ -77,23 +77,6 @@ jobs: steps: - run: exit 0 - testMacOS: - name: macOS (Basic, Production, Acceptance) - runs-on: macos-latest - env: - NEXT_TELEMETRY_DISABLED: 1 - NEXT_TEST_JOB: 1 - HEADLESS: true - - steps: - - uses: actions/checkout@v2 - - run: git fetch --depth=1 origin +refs/tags/*:refs/tags/* - # Installing dependencies again since OS changed - - run: yarn install --frozen-lockfile --check-files || yarn install --frozen-lockfile --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/acceptance/* - testWebpack5: name: webpack 5 (Basic, Production, Acceptance) runs-on: ubuntu-latest diff --git a/.github/workflows/test_macos.yml b/.github/workflows/test_macos.yml new file mode 100644 index 0000000000000..09ff576abd913 --- /dev/null +++ b/.github/workflows/test_macos.yml @@ -0,0 +1,22 @@ +on: + push: + branches: [canary] + +name: Test macOS + +jobs: + testMacOS: + name: macOS (Basic, Production, Acceptance) + runs-on: macos-latest + env: + NEXT_TELEMETRY_DISABLED: 1 + NEXT_TEST_JOB: 1 + HEADLESS: true + + steps: + - uses: actions/checkout@v2 + - run: git fetch --depth=1 origin +refs/tags/*:refs/tags/* + - run: yarn install --frozen-lockfile --check-files || yarn install --frozen-lockfile --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/acceptance/* diff --git a/docs/api-reference/cli.md b/docs/api-reference/cli.md index badf0e9510376..e97d2b2d59a4d 100644 --- a/docs/api-reference/cli.md +++ b/docs/api-reference/cli.md @@ -48,6 +48,14 @@ NODE_OPTIONS='--inspect' next The first load is colored green, yellow, or red. Aim for green for performant applications. +You can enable production profiling for React with the `--profile` flag in `next build`. This requires Next.js 9.5: + +```bash +next build --profile +``` + +After that, you can use the profiler in the same way as you would in development. + ## Development `next dev` starts the application in development mode with hot-code reloading, error reporting, and more: diff --git a/docs/api-reference/next.config.js/runtime-configuration.md b/docs/api-reference/next.config.js/runtime-configuration.md index 4a4a2a5ace6e1..b52e3e8898560 100644 --- a/docs/api-reference/next.config.js/runtime-configuration.md +++ b/docs/api-reference/next.config.js/runtime-configuration.md @@ -6,8 +6,6 @@ description: Add client and server runtime configuration to your Next.js app. > Generally you'll want to use [build-time environment variables](/docs/api-reference/next.config.js/environment-variables.md) to provide your configuration. The reason for this is that runtime configuration adds rendering / initialization overhead and is incompatible with [Automatic Static Optimization](/docs/advanced-features/automatic-static-optimization.md). -> Runtime configuration is not available when using the [`serverless` target](/docs/api-reference/next.config.js/build-target.md#serverless-target). - To add runtime configuration to your app open `next.config.js` and add the `publicRuntimeConfig` and `serverRuntimeConfig` configs: ```js diff --git a/errors/invalid-external-rewrite.md b/errors/invalid-external-rewrite.md new file mode 100644 index 0000000000000..89d114af835bf --- /dev/null +++ b/errors/invalid-external-rewrite.md @@ -0,0 +1,13 @@ +# Invalid External Rewrite + +#### Why This Error Occurred + +A rewrite was defined with both `basePath: false` and an internal `destination`. Rewrites that capture urls outside of the `basePath` must route externally, as they are intended for proxying in the case of incremental adoption of Next.js in a project. + +#### Possible Ways to Fix It + +Look for any rewrite where `basePath` is `false` and make sure its `destination` starts with `http://` or `https://`. + +### Useful Links + +- [Rewrites section in Documentation](https://nextjs.org/docs/api-reference/next.config.js/rewrites) diff --git a/examples/blog-starter-typescript/components/alert.tsx b/examples/blog-starter-typescript/components/alert.tsx index 9e07d7736c30c..f804ff6192868 100644 --- a/examples/blog-starter-typescript/components/alert.tsx +++ b/examples/blog-starter-typescript/components/alert.tsx @@ -18,7 +18,7 @@ const Alert = ({ preview }: Props) => {
{this.props.query.slug}
+ return{this.props.query.slug}
} } diff --git a/test/integration/client-navigation/pages/nav/query-only.js b/test/integration/client-navigation/pages/nav/query-only.js new file mode 100644 index 0000000000000..af030dbea23e3 --- /dev/null +++ b/test/integration/client-navigation/pages/nav/query-only.js @@ -0,0 +1,24 @@ +import Link from 'next/link' +import { useRouter } from 'next/router' + +export async function getServerSideProps({ query: { prop = '' } }) { + return { props: { prop } } +} + +export default function Page({ prop }) { + const router = useRouter() + return ( + <> +{JSON.stringify(serverRuntimeConfig)}
+{JSON.stringify(publicRuntimeConfig)}
+hi fallback
+ } + + return ( + <> +Post: {post}
+ time: {time} +hi fallback
+ } + + return ( + <> +Post: {post}
+ time: {time} +hi fallback
+ } + + return ( + <> +Post: {post}
+ time: {time} +hello blocking {time.toString()}
+} + +export default Page diff --git a/test/integration/prerender/test/index.test.js b/test/integration/prerender/test/index.test.js index b47d5d6b88bc5..67a1e8eee22c1 100644 --- a/test/integration/prerender/test/index.test.js +++ b/test/integration/prerender/test/index.test.js @@ -142,6 +142,16 @@ const expectedManifestRoutes = () => ({ initialRevalidateSeconds: 1, srcRoute: null, }, + '/blocking-fallback-some/a': { + dataRoute: `/_next/data/${buildId}/blocking-fallback-some/a.json`, + initialRevalidateSeconds: 1, + srcRoute: '/blocking-fallback-some/[slug]', + }, + '/blocking-fallback-some/b': { + dataRoute: `/_next/data/${buildId}/blocking-fallback-some/b.json`, + initialRevalidateSeconds: 1, + srcRoute: '/blocking-fallback-some/[slug]', + }, '/blog': { dataRoute: `/_next/data/${buildId}/blog.json`, initialRevalidateSeconds: 10, @@ -418,6 +428,20 @@ const runTests = (dev = false, isEmulatedServerless = false) => { expect(html).toMatch(/Post:.*?post-1/) }) + it('should SSR blocking path correctly (blocking)', async () => { + const html = await renderViaHTTP(appPort, '/blocking-fallback/random-path') + const $ = cheerio.load(html) + expect(JSON.parse($('#__NEXT_DATA__').text()).isFallback).toBe(false) + expect($('p').text()).toBe('Post: random-path') + }) + + it('should SSR blocking path correctly (pre-rendered)', async () => { + const html = await renderViaHTTP(appPort, '/blocking-fallback-some/a') + const $ = cheerio.load(html) + expect(JSON.parse($('#__NEXT_DATA__').text()).isFallback).toBe(false) + expect($('p').text()).toBe('Post: a') + }) + it('should have gsp in __NEXT_DATA__', async () => { const html = await renderViaHTTP(appPort, '/') const $ = cheerio.load(html) @@ -729,6 +753,11 @@ const runTests = (dev = false, isEmulatedServerless = false) => { expect(html).toMatch(/About:.*?en/) }) + it("should allow rewriting to SSG page with fallback: 'blocking'", async () => { + const html = await renderViaHTTP(appPort, '/blocked-create') + expect(html).toMatch(/Post:.*?blocked-create/) + }) + it('should fetch /_next/data correctly with mismatched href and as', async () => { const browser = await webdriver(appPort, '/') @@ -825,6 +854,28 @@ const runTests = (dev = false, isEmulatedServerless = false) => { expect(JSON.parse($2('#__NEXT_DATA__').text()).isFallback).toBe(false) }) + it('should never show fallback for page not in getStaticPaths when blocking', async () => { + const html = await renderViaHTTP(appPort, '/blocking-fallback-some/asf') + const $ = cheerio.load(html) + expect(JSON.parse($('#__NEXT_DATA__').text()).isFallback).toBe(false) + + // make another request to ensure it still is + const html2 = await renderViaHTTP(appPort, '/blocking-fallback-some/asf') + const $2 = cheerio.load(html2) + expect(JSON.parse($2('#__NEXT_DATA__').text()).isFallback).toBe(false) + }) + + it('should not show fallback for page in getStaticPaths when blocking', async () => { + const html = await renderViaHTTP(appPort, '/blocking-fallback-some/b') + const $ = cheerio.load(html) + expect(JSON.parse($('#__NEXT_DATA__').text()).isFallback).toBe(false) + + // make another request to ensure it's still not + const html2 = await renderViaHTTP(appPort, '/blocking-fallback-some/b') + const $2 = cheerio.load(html2) + expect(JSON.parse($2('#__NEXT_DATA__').text()).isFallback).toBe(false) + }) + it('should log error in console and browser in dev mode', async () => { const origContent = await fs.readFile(indexPage, 'utf8') @@ -964,6 +1015,11 @@ const runTests = (dev = false, isEmulatedServerless = false) => { expect(html).toContain('"isFallback":true') }) + it('should not fallback before invalid JSON is returned from getStaticProps when blocking fallback', async () => { + const html = await renderViaHTTP(appPort, '/non-json-blocking/foobar') + expect(html).toContain('"isFallback":false') + }) + it('should show error for invalid JSON returned from getStaticProps on SSR', async () => { const browser = await webdriver(appPort, '/non-json/direct') @@ -1057,6 +1113,42 @@ const runTests = (dev = false, isEmulatedServerless = false) => { ), page: '/bad-ssr', }, + { + dataRouteRegex: normalizeRegEx( + `^\\/_next\\/data\\/${escapeRegex( + buildId + )}\\/blocking\\-fallback\\/([^\\/]+?)\\.json$` + ), + namedDataRouteRegex: `^/_next/data/${escapeRegex( + buildId + )}/blocking\\-fallback/(?[^/]+?)\\.json$`, + dataRouteRegex: normalizeRegEx( + `^\\/_next\\/data\\/${escapeRegex( + buildId + )}\\/non\\-json\\-blocking\\/([^\\/]+?)\\.json$` + ), + page: '/non-json-blocking/[p]', + routeKeys: { + p: 'p', + }, + }, { dataRouteRegex: normalizeRegEx( `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/something.json$` @@ -1247,6 +1353,36 @@ const runTests = (dev = false, isEmulatedServerless = false) => { expect(manifest.version).toBe(2) expect(manifest.routes).toEqual(expectedManifestRoutes()) expect(manifest.dynamicRoutes).toEqual({ + '/blocking-fallback-once/[slug]': { + dataRoute: `/_next/data/${buildId}/blocking-fallback-once/[slug].json`, + dataRouteRegex: normalizeRegEx( + `^\\/_next\\/data\\/${escapedBuildId}\\/blocking\\-fallback\\-once\\/([^\\/]+?)\\.json$` + ), + fallback: null, + routeRegex: normalizeRegEx( + '^\\/blocking\\-fallback\\-once\\/([^\\/]+?)(?:\\/)?$' + ), + }, + '/blocking-fallback-some/[slug]': { + dataRoute: `/_next/data/${buildId}/blocking-fallback-some/[slug].json`, + dataRouteRegex: normalizeRegEx( + `^\\/_next\\/data\\/${escapedBuildId}\\/blocking\\-fallback\\-some\\/([^\\/]+?)\\.json$` + ), + fallback: null, + routeRegex: normalizeRegEx( + '^\\/blocking\\-fallback\\-some\\/([^\\/]+?)(?:\\/)?$' + ), + }, + '/blocking-fallback/[slug]': { + dataRoute: `/_next/data/${buildId}/blocking-fallback/[slug].json`, + dataRouteRegex: normalizeRegEx( + `^\\/_next\\/data\\/${escapedBuildId}\\/blocking\\-fallback\\/([^\\/]+?)\\.json$` + ), + fallback: null, + routeRegex: normalizeRegEx( + '^\\/blocking\\-fallback\\/([^\\/]+?)(?:\\/)?$' + ), + }, '/blog/[post]': { fallback: '/blog/[post].html', dataRoute: `/_next/data/${buildId}/blog/[post].json`, @@ -1291,6 +1427,16 @@ const runTests = (dev = false, isEmulatedServerless = false) => { fallback: false, routeRegex: normalizeRegEx('^\\/lang\\/([^\\/]+?)\\/about(?:\\/)?$'), }, + '/non-json-blocking/[p]': { + dataRoute: `/_next/data/${buildId}/non-json-blocking/[p].json`, + dataRouteRegex: normalizeRegEx( + `^\\/_next\\/data\\/${escapedBuildId}\\/non\\-json\\-blocking\\/([^\\/]+?)\\.json$` + ), + fallback: null, + routeRegex: normalizeRegEx( + '^\\/non\\-json\\-blocking\\/([^\\/]+?)(?:\\/)?$' + ), + }, '/non-json/[p]': { dataRoute: `/_next/data/${buildId}/non-json/[p].json`, dataRouteRegex: normalizeRegEx( @@ -1386,6 +1532,20 @@ const runTests = (dev = false, isEmulatedServerless = false) => { }) if (!isEmulatedServerless) { + it('should not revalidate when set to false in blocking fallback mode', async () => { + const route = '/blocking-fallback-once/test-no-revalidate' + + const initialHtml = await renderViaHTTP(appPort, route) + let newHtml = await renderViaHTTP(appPort, route) + expect(initialHtml).toBe(newHtml) + + newHtml = await renderViaHTTP(appPort, route) + expect(initialHtml).toBe(newHtml) + + newHtml = await renderViaHTTP(appPort, route) + expect(initialHtml).toBe(newHtml) + }) + it('should handle revalidating HTML correctly', async () => { const route = '/blog/post-2/comment-2' const initialHtml = await renderViaHTTP(appPort, route) @@ -1423,6 +1583,80 @@ const runTests = (dev = false, isEmulatedServerless = false) => { expect(newJson).toMatch(/post-2/) expect(newJson).toMatch(/comment-3/) }) + + it('should handle revalidating HTML correctly with blocking', async () => { + const route = '/blocking-fallback/pewpew' + const initialHtml = await renderViaHTTP(appPort, route) + expect(initialHtml).toMatch(/Post:.*?pewpew/) + + let newHtml = await renderViaHTTP(appPort, route) + expect(newHtml).toBe(initialHtml) + + await waitFor(2 * 1000) + await renderViaHTTP(appPort, route) + + await waitFor(2 * 1000) + newHtml = await renderViaHTTP(appPort, route) + expect(newHtml === initialHtml).toBe(false) + expect(newHtml).toMatch(/Post:.*?pewpew/) + }) + + it('should handle revalidating JSON correctly with blocking', async () => { + const route = `/_next/data/${buildId}/blocking-fallback/pewpewdata.json` + const initialJson = await renderViaHTTP(appPort, route) + expect(initialJson).toMatch(/pewpewdata/) + + let newJson = await renderViaHTTP(appPort, route) + expect(newJson).toBe(initialJson) + + await waitFor(2 * 1000) + await renderViaHTTP(appPort, route) + + await waitFor(2 * 1000) + newJson = await renderViaHTTP(appPort, route) + expect(newJson === initialJson).toBe(false) + expect(newJson).toMatch(/pewpewdata/) + }) + + it('should handle revalidating HTML correctly with blocking and seed', async () => { + const route = '/blocking-fallback/a' + const initialHtml = await renderViaHTTP(appPort, route) + const $initial = cheerio.load(initialHtml) + expect($initial('p').text()).toBe('Post: a') + + let newHtml = await renderViaHTTP(appPort, route) + expect(newHtml).toBe(initialHtml) + + await waitFor(2 * 1000) + await renderViaHTTP(appPort, route) + + await waitFor(2 * 1000) + newHtml = await renderViaHTTP(appPort, route) + expect(newHtml === initialHtml).toBe(false) + const $new = cheerio.load(newHtml) + expect($new('p').text()).toBe('Post: a') + }) + + it('should handle revalidating JSON correctly with blocking and seed', async () => { + const route = `/_next/data/${buildId}/blocking-fallback/b.json` + const initialJson = await renderViaHTTP(appPort, route) + expect(JSON.parse(initialJson)).toMatchObject({ + pageProps: { params: { slug: 'b' } }, + }) + + let newJson = await renderViaHTTP(appPort, route) + expect(newJson).toBe(initialJson) + + await waitFor(2 * 1000) + await renderViaHTTP(appPort, route) + + await waitFor(2 * 1000) + newJson = await renderViaHTTP(appPort, route) + expect(newJson === initialJson).toBe(false) + expect(JSON.parse(newJson)).toMatchObject({ + pageProps: { params: { slug: 'b' } }, + }) + }) } it('should not fetch prerender data on mount', async () => { @@ -1487,6 +1721,10 @@ describe('SSG Prerender', () => { { source: '/about', destination: '/lang/en/about' + }, + { + source: '/blocked-create', + destination: '/blocking-fallback/blocked-create', } ] } @@ -1589,6 +1827,10 @@ describe('SSG Prerender', () => { { source: '/about', destination: '/lang/en/about' + }, + { + source: '/blocked-create', + destination: '/blocking-fallback/blocked-create', } ] } @@ -1768,10 +2010,17 @@ describe('SSG Prerender', () => { '/blog/[post]/index.js', '/fallback-only/[slug].js', ] + const fallbackBlockingPages = [ + '/blocking-fallback/[slug].js', + '/blocking-fallback-once/[slug].js', + '/blocking-fallback-some/[slug].js', + '/non-json-blocking/[p].js', + ] const brokenPages = ['/bad-gssp.js', '/bad-ssr.js'] const fallbackTruePageContents = {} + const fallbackBlockingPageContents = {} beforeAll(async () => { exportDir = join(appDir, 'out') @@ -1802,6 +2051,18 @@ describe('SSG Prerender', () => { ) } + for (const page of fallbackBlockingPages) { + const pagePath = join(appDir, 'pages', page) + fallbackBlockingPageContents[page] = await fs.readFile(pagePath, 'utf8') + await fs.writeFile( + pagePath, + fallbackBlockingPageContents[page].replace( + "fallback: 'unstable_blocking'", + 'fallback: false' + ) + ) + } + for (const page of brokenPages) { const pagePath = join(appDir, 'pages', page) await fs.rename(pagePath, `${pagePath}.bak`) @@ -1819,10 +2080,14 @@ describe('SSG Prerender', () => { for (const page of fallbackTruePages) { const pagePath = join(appDir, 'pages', page) - await fs.writeFile(pagePath, fallbackTruePageContents[page]) } + for (const page of fallbackBlockingPages) { + const pagePath = join(appDir, 'pages', page) + await fs.writeFile(pagePath, fallbackBlockingPageContents[page]) + } + for (const page of brokenPages) { const pagePath = join(appDir, 'pages', page) await fs.rename(`${pagePath}.bak`, pagePath) diff --git a/test/integration/size-limit/test/index.test.js b/test/integration/size-limit/test/index.test.js index 08f7bb4591540..36628b7511dcc 100644 --- a/test/integration/size-limit/test/index.test.js +++ b/test/integration/size-limit/test/index.test.js @@ -100,7 +100,7 @@ describe('Production response size', () => { ) // These numbers are without gzip compression! - const delta = responseSizesBytes - 166 * 1024 + const delta = responseSizesBytes - 167 * 1024 expect(delta).toBeLessThanOrEqual(1024) // don't increase size more than 1kb expect(delta).toBeGreaterThanOrEqual(-1024) // don't decrease size more than 1kb without updating target }) diff --git a/test/unit/webpack-config-overrides.test.js b/test/unit/webpack-config-overrides.test.js new file mode 100644 index 0000000000000..2a0af487e679a --- /dev/null +++ b/test/unit/webpack-config-overrides.test.js @@ -0,0 +1,239 @@ +/* eslint-env jest */ +import { attachReactRefresh } from 'next/dist/build/webpack-config' + +describe('webpack-config attachReactRefresh', () => { + it('should skip adding when unrelated', () => { + const input = { module: { rules: [{ use: 'a' }] } } + const expected = { module: { rules: [{ use: 'a' }] } } + + attachReactRefresh(input, 'rr') + expect(input).toEqual(expected) + }) + + it('should skip adding when existing (shorthand)', () => { + const input = { + module: { + rules: [{ use: ['@next/react-refresh-utils/loader', 'rr'] }], + }, + } + const expected = { + module: { + rules: [{ use: ['@next/react-refresh-utils/loader', 'rr'] }], + }, + } + + attachReactRefresh(input, 'rr') + expect(input).toEqual(expected) + }) + + it('should skip adding when existing (longhand)', () => { + const input = { + module: { + rules: [ + { use: [require.resolve('@next/react-refresh-utils/loader'), 'rr'] }, + ], + }, + } + const expected = { + module: { + rules: [ + { use: [require.resolve('@next/react-refresh-utils/loader'), 'rr'] }, + ], + }, + } + + attachReactRefresh(input, 'rr') + expect(input).toEqual(expected) + }) + + it('should add when missing (single, non-array)', () => { + const input = { + module: { + rules: [{ use: 'rr' }], + }, + } + + attachReactRefresh(input, 'rr') + expect(input).toMatchObject({ + module: { + rules: [ + { + use: [ + expect.stringMatching(/react-refresh-utils[\\/]loader\.js/), + 'rr', + ], + }, + ], + }, + }) + }) + + it('should add when missing (single, array)', () => { + const input = { + module: { + rules: [{ use: ['rr'] }], + }, + } + + attachReactRefresh(input, 'rr') + expect(input).toMatchObject({ + module: { + rules: [ + { + use: [ + expect.stringMatching(/react-refresh-utils[\\/]loader\.js/), + 'rr', + ], + }, + ], + }, + }) + }) + + it('should add when missing (before, array)', () => { + const input = { + module: { + rules: [{ use: ['bla', 'rr'] }], + }, + } + + attachReactRefresh(input, 'rr') + expect(input).toMatchObject({ + module: { + rules: [ + { + use: [ + 'bla', + expect.stringMatching(/react-refresh-utils[\\/]loader\.js/), + 'rr', + ], + }, + ], + }, + }) + }) + + it('should add when missing (after, array)', () => { + const input = { + module: { + rules: [{ use: ['rr', 'bla'] }], + }, + } + + attachReactRefresh(input, 'rr') + expect(input).toMatchObject({ + module: { + rules: [ + { + use: [ + expect.stringMatching(/react-refresh-utils[\\/]loader\.js/), + 'rr', + 'bla', + ], + }, + ], + }, + }) + }) + + it('should add when missing (multi, array)', () => { + const input = { + module: { + rules: [{ use: ['hehe', 'haha', 'rawr', 'rr', 'lol', 'bla'] }], + }, + } + + attachReactRefresh(input, 'rr') + expect(input).toMatchObject({ + module: { + rules: [ + { + use: [ + 'hehe', + 'haha', + 'rawr', + expect.stringMatching(/react-refresh-utils[\\/]loader\.js/), + 'rr', + 'lol', + 'bla', + ], + }, + ], + }, + }) + }) + + it('should skip when present (multi, array)', () => { + const input = { + module: { + rules: [ + { + use: [ + 'hehe', + 'haha', + '@next/react-refresh-utils/loader', + 'rr', + 'lol', + 'bla', + ], + }, + ], + }, + } + + attachReactRefresh(input, 'rr') + expect(input).toMatchObject({ + module: { + rules: [ + { + use: [ + 'hehe', + 'haha', + '@next/react-refresh-utils/loader', + 'rr', + 'lol', + 'bla', + ], + }, + ], + }, + }) + }) + + it('should skip when present (multi, array, wrong order)', () => { + const input = { + module: { + rules: [ + { + use: [ + 'hehe', + 'haha', + 'rr', + 'lol', + '@next/react-refresh-utils/loader', + 'bla', + ], + }, + ], + }, + } + + attachReactRefresh(input, 'rr') + expect(input).toMatchObject({ + module: { + rules: [ + { + use: [ + 'hehe', + 'haha', + 'rr', + 'lol', + '@next/react-refresh-utils/loader', + 'bla', + ], + }, + ], + }, + }) + }) +})