Enable Fast Refresh by Default (#12640)

This commit is contained in:
Joe Haddad 2020-05-10 19:25:57 -04:00 committed by GitHub
parent 0bc0760356
commit ae1daea355
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 207 additions and 148 deletions

View file

@ -94,6 +94,9 @@ export default function connect(options) {
subscribeToHmrEvent(handler) {
customHmrEventHandler = handler
},
onUnrecoverableError() {
hadRuntimeError = true
},
reportRuntimeError(err) {
if (process.env.__NEXT_FAST_REFRESH) {
return

View file

@ -96,7 +96,7 @@ class Container extends React.Component {
(isFallback ||
(data.nextExport &&
(isDynamicRoute(router.pathname) || location.search)) ||
(props.__N_SSG && location.search))
(props && props.__N_SSG && location.search))
) {
// update query on mount for exported pages
router.replace(
@ -287,6 +287,10 @@ export function renderError(props) {
// In production we catch runtime errors using componentDidCatch which will trigger renderError
if (process.env.NODE_ENV !== 'production') {
if (process.env.__NEXT_FAST_REFRESH) {
// A Next.js rendering runtime error is always unrecoverable
// FIXME: let's make this recoverable (error in GIP client-transition)
webpackHMR.onUnrecoverableError()
const { getNodeError } = require('@next/react-dev-overlay/lib/client')
// Server-side runtime errors need to be re-thrown on the client-side so
// that the overlay is rendered.

View file

@ -52,7 +52,7 @@ const defaultConfig: { [key: string]: any } = {
basePath: '',
sassOptions: {},
pageEnv: false,
reactRefresh: false,
reactRefresh: true,
},
future: {
excludeDefaultMomentLocales: false,

View file

@ -1,11 +1,11 @@
import PropTypes from 'prop-types'
import React, { Component } from 'react'
import flush from 'styled-jsx/server'
import {
CLIENT_STATIC_FILES_RUNTIME_AMP,
CLIENT_STATIC_FILES_RUNTIME_WEBPACK,
AMP_RENDER_TARGET,
CLIENT_STATIC_FILES_RUNTIME_AMP,
CLIENT_STATIC_FILES_RUNTIME_REACT_REFRESH,
CLIENT_STATIC_FILES_RUNTIME_WEBPACK,
} from '../next-server/lib/constants'
import { DocumentContext as DocumentComponentContext } from '../next-server/lib/document-context'
import {
@ -661,6 +661,7 @@ export class NextScript extends Component<OriginProps> {
}
const devFiles = [
CLIENT_STATIC_FILES_RUNTIME_REACT_REFRESH,
CLIENT_STATIC_FILES_RUNTIME_AMP,
CLIENT_STATIC_FILES_RUNTIME_WEBPACK,
]

View file

@ -20,7 +20,11 @@ export default function() {
// Legacy CSS implementations will `eval` browser code in a Node.js context
// to extract CSS. For backwards compatibility, we need to check we're in a
// browser context before continuing.
if (typeof self !== 'undefined') {
if (
typeof self !== 'undefined' &&
// AMP / No-JS mode does not inject these helpers:
'$RefreshHelpers$' in self
) {
const currentExports = module.__proto__.exports
const prevExports = module.hot.data?.prevExports ?? null

View file

@ -43,6 +43,10 @@ declare const module: {
}
}
function isSafeExport(key: string): boolean {
return key === '__esModule' || key === '__N_SSG' || key === '__N_SSP'
}
function registerExportsForReactRefresh(
moduleExports: unknown,
moduleID: string
@ -54,6 +58,9 @@ function registerExportsForReactRefresh(
return
}
for (const key in moduleExports) {
if (isSafeExport(key)) {
continue
}
const exportValue = moduleExports[key]
const typeID = moduleID + ' %exports% ' + key
RefreshRuntime.register(exportValue, typeID)
@ -72,7 +79,7 @@ function isReactRefreshBoundary(moduleExports: unknown): boolean {
let areAllExportsComponents = true
for (const key in moduleExports) {
hasExports = true
if (key === '__esModule') {
if (isSafeExport(key)) {
continue
}
const exportValue = moduleExports[key]
@ -109,7 +116,7 @@ function getRefreshBoundarySignature(moduleExports: unknown): Array<unknown> {
return signature
}
for (const key in moduleExports) {
if (key === '__esModule') {
if (isSafeExport(key)) {
continue
}
const exportValue = moduleExports[key]

View file

@ -392,7 +392,7 @@ describe('AMP Usage', () => {
const html = await renderViaHTTP(dynamicAppPort, '/only-amp')
const $ = cheerio.load(html)
expect($('html').attr('data-ampdevmode')).toBe('')
expect($('script[data-ampdevmode]').length).toBe(3)
expect($('script[data-ampdevmode]').length).toBe(4)
})
it('should detect the changes and display it', async () => {

View file

@ -1,13 +1,15 @@
/* eslint-env jest */
import webdriver from 'next-webdriver'
import { join } from 'path'
import {
check,
File,
waitFor,
getReactErrorOverlayContent,
getBrowserBodyText,
getRedboxHeader,
getRedboxSource,
hasRedbox,
waitFor,
} from 'next-test-utils'
import webdriver from 'next-webdriver'
import { join } from 'path'
export default (context, renderViaHTTP) => {
describe('Error Recovery', () => {
@ -46,40 +48,6 @@ export default (context, renderViaHTTP) => {
}
})
it('should have installed the react-overlay-editor editor handler', async () => {
let browser
const aboutPage = new File(
join(__dirname, '../', 'pages', 'hmr', 'about1.js')
)
try {
aboutPage.replace('</div>', 'div')
browser = await webdriver(context.appPort, '/hmr/about1')
// react-error-overlay uses the following inline style if an editorHandler is installed
expect(await getReactErrorOverlayContent(browser)).toMatch(
/style="cursor: pointer;"/
)
aboutPage.restore()
await check(() => getBrowserBodyText(browser), /This is the about page/)
} catch (err) {
aboutPage.restore()
if (browser) {
await check(
() => getBrowserBodyText(browser),
/This is the about page/
)
}
throw err
} finally {
if (browser) {
await browser.close()
}
}
})
it('should detect syntax errors and recover', async () => {
let browser
const aboutPage = new File(
@ -91,7 +59,8 @@ export default (context, renderViaHTTP) => {
aboutPage.replace('</div>', 'div')
expect(await getReactErrorOverlayContent(browser)).toMatch(
expect(await hasRedbox(browser)).toBe(true)
expect(await getRedboxSource(browser)).toMatch(
/Unterminated JSX contents/
)
@ -127,7 +96,8 @@ export default (context, renderViaHTTP) => {
browser = await webdriver(context.appPort, '/hmr/contact')
expect(await getReactErrorOverlayContent(browser)).toMatch(
expect(await hasRedbox(browser)).toBe(true)
expect(await getRedboxSource(browser)).toMatch(
/Unterminated JSX contents/
)
@ -166,9 +136,8 @@ export default (context, renderViaHTTP) => {
aboutPage.replace('export', 'aa=20;\nexport')
expect(await getReactErrorOverlayContent(browser)).toMatch(
/aa is not defined/
)
expect(await hasRedbox(browser)).toBe(true)
expect(await getRedboxHeader(browser)).toMatch(/aa is not defined/)
aboutPage.restore()
@ -195,9 +164,8 @@ export default (context, renderViaHTTP) => {
'throw new Error("an-expected-error");\nreturn'
)
expect(await getReactErrorOverlayContent(browser)).toMatch(
/an-expected-error/
)
expect(await hasRedbox(browser)).toBe(true)
expect(await getRedboxSource(browser)).toMatch(/an-expected-error/)
aboutPage.restore()
@ -234,7 +202,7 @@ export default (context, renderViaHTTP) => {
)
await check(
() => getBrowserBodyText(browser),
() => getRedboxHeader(browser),
/The default export is not a React Component/
)
@ -274,7 +242,7 @@ export default (context, renderViaHTTP) => {
)
await check(
() => getBrowserBodyText(browser),
() => getRedboxHeader(browser),
/Objects are not valid as a React child/
)
@ -314,8 +282,7 @@ export default (context, renderViaHTTP) => {
)
await check(async () => {
const txt = await getBrowserBodyText(browser)
console.log(txt)
const txt = await getRedboxHeader(browser)
return txt
}, /The default export is not a React Component/)
@ -349,7 +316,8 @@ export default (context, renderViaHTTP) => {
browser = await webdriver(context.appPort, '/hmr')
await browser.elementByCss('#error-in-gip-link').click()
expect(await getReactErrorOverlayContent(browser)).toMatch(
expect(await hasRedbox(browser)).toBe(true)
expect(await getRedboxSource(browser)).toMatch(
/an-expected-error-in-gip/
)
@ -366,7 +334,7 @@ export default (context, renderViaHTTP) => {
await waitFor(2000)
throw new Error('waiting')
}
return getReactErrorOverlayContent(browser)
return getRedboxSource(browser)
}, /an-expected-error-in-gip/)
} catch (err) {
erroredPage.restore()
@ -387,7 +355,8 @@ export default (context, renderViaHTTP) => {
try {
browser = await webdriver(context.appPort, '/hmr/error-in-gip')
expect(await getReactErrorOverlayContent(browser)).toMatch(
expect(await hasRedbox(browser)).toBe(true)
expect(await getRedboxSource(browser)).toMatch(
/an-expected-error-in-gip/
)
@ -407,7 +376,7 @@ export default (context, renderViaHTTP) => {
await waitFor(2000)
throw new Error('waiting')
}
return getReactErrorOverlayContent(browser)
return getRedboxSource(browser)
}, /an-expected-error-in-gip/)
} catch (err) {
erroredPage.restore()

View file

@ -1,17 +1,19 @@
/* eslint-env jest */
/* global jasmine */
import { join } from 'path'
import webdriver from 'next-webdriver'
import renderingSuite from './rendering'
import {
waitFor,
findPort,
killApp,
launchApp,
fetchViaHTTP,
findPort,
getRedboxSource,
hasRedbox,
killApp,
getRedboxHeader,
launchApp,
renderViaHTTP,
getReactErrorOverlayContent,
waitFor,
} from 'next-test-utils'
import webdriver from 'next-webdriver'
import { join } from 'path'
import renderingSuite from './rendering'
const context = {}
jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000 * 60 * 5
@ -209,10 +211,8 @@ describe('Client Navigation', () => {
try {
browser = await webdriver(context.appPort, '/nav')
await browser.elementByCss('#empty-props').click()
await waitFor(3000)
expect(await getReactErrorOverlayContent(browser)).toMatch(
expect(await hasRedbox(browser)).toBe(true)
expect(await getRedboxHeader(browser)).toMatch(
/should resolve to an object\. But found "null" instead\./
)
} finally {
@ -953,10 +953,10 @@ describe('Client Navigation', () => {
let browser
try {
browser = await webdriver(context.appPort, '/error-inside-browser-page')
await waitFor(3000)
const text = await getReactErrorOverlayContent(browser)
expect(await hasRedbox(browser)).toBe(true)
const text = await getRedboxSource(browser)
expect(text).toMatch(/An Expected error occurred/)
expect(text).toMatch(/pages\/error-inside-browser-page\.js:5/)
expect(text).toMatch(/pages[\\/]error-inside-browser-page\.js \(5:12\)/)
} finally {
if (browser) {
await browser.close()
@ -971,10 +971,10 @@ describe('Client Navigation', () => {
context.appPort,
'/error-in-the-browser-global-scope'
)
await waitFor(3000)
const text = await getReactErrorOverlayContent(browser)
expect(await hasRedbox(browser)).toBe(true)
const text = await getRedboxSource(browser)
expect(text).toMatch(/An Expected error occurred/)
expect(text).toMatch(/error-in-the-browser-global-scope\.js:2/)
expect(text).toMatch(/error-in-the-browser-global-scope\.js \(2:8\)/)
} finally {
if (browser) {
await browser.close()

View file

@ -204,9 +204,9 @@ export default function(render, fetch) {
test('getInitialProps circular structure', async () => {
const $ = await get$('/circular-json-error')
const expectedErrorMessage =
'Circular structure in "getInitialProps" result of page "/circular-json-error".'
'Circular structure in \\"getInitialProps\\" result of page \\"/circular-json-error\\".'
expect(
$('pre')
$('#__NEXT_DATA__')
.text()
.includes(expectedErrorMessage)
).toBeTruthy()
@ -215,9 +215,9 @@ export default function(render, fetch) {
test('getInitialProps should be class method', async () => {
const $ = await get$('/instance-get-initial-props')
const expectedErrorMessage =
'"InstanceInitialPropsPage.getInitialProps()" is defined as an instance method - visit https://err.sh/zeit/next.js/get-initial-props-as-an-instance-method for more information.'
'\\"InstanceInitialPropsPage.getInitialProps()\\" is defined as an instance method - visit https://err.sh/zeit/next.js/get-initial-props-as-an-instance-method for more information.'
expect(
$('pre')
$('#__NEXT_DATA__')
.text()
.includes(expectedErrorMessage)
).toBeTruthy()
@ -226,9 +226,9 @@ export default function(render, fetch) {
test('getInitialProps resolves to null', async () => {
const $ = await get$('/empty-get-initial-props')
const expectedErrorMessage =
'"EmptyInitialPropsPage.getInitialProps()" should resolve to an object. But found "null" instead.'
'\\"EmptyInitialPropsPage.getInitialProps()\\" should resolve to an object. But found \\"null\\" instead.'
expect(
$('pre')
$('#__NEXT_DATA__')
.text()
.includes(expectedErrorMessage)
).toBeTruthy()
@ -282,19 +282,19 @@ export default function(render, fetch) {
test('default export is not a React Component', async () => {
const $ = await get$('/no-default-export')
const pre = $('pre')
const pre = $('#__NEXT_DATA__')
expect(pre.text()).toMatch(/The default export is not a React Component/)
})
test('error-inside-page', async () => {
const $ = await get$('/error-inside-page')
expect($('pre').text()).toMatch(/This is an expected error/)
expect($('#__NEXT_DATA__').text()).toMatch(/This is an expected error/)
// Sourcemaps are applied by react-error-overlay, so we can't check them on SSR.
})
test('error-in-the-global-scope', async () => {
const $ = await get$('/error-in-the-global-scope')
expect($('pre').text()).toMatch(/aa is not defined/)
expect($('#__NEXT_DATA__').text()).toMatch(/aa is not defined/)
// Sourcemaps are applied by react-error-overlay, so we can't check them on SSR.
})

View file

@ -8,6 +8,7 @@ import {
fetchViaHTTP,
findPort,
getBrowserBodyText,
getRedboxHeader,
killApp,
launchApp,
nextBuild,
@ -471,7 +472,7 @@ const runTests = (dev = false) => {
await browser.elementByCss('#non-json').click()
await check(
() => getBrowserBodyText(browser),
() => getRedboxHeader(browser),
/Error serializing `.time` returned from `getServerSideProps`/
)
})

View file

@ -1,16 +1,17 @@
/* eslint-env jest */
/* global jasmine */
import { join } from 'path'
import webdriver from 'next-webdriver'
import {
findPort,
launchApp,
getRedboxHeader,
hasRedbox,
killApp,
nextStart,
launchApp,
nextBuild,
getReactErrorOverlayContent,
nextStart,
waitFor,
} from 'next-test-utils'
import webdriver from 'next-webdriver'
import { join } from 'path'
jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000 * 60 * 5
let app
@ -53,7 +54,8 @@ const showsError = async (
console.log(warnLogs)
expect(warnLogs.some(log => log.match(regex))).toBe(true)
} else {
const errorContent = await getReactErrorOverlayContent(browser)
expect(await hasRedbox(browser)).toBe(true)
const errorContent = await getRedboxHeader(browser)
expect(errorContent).toMatch(regex)
}

View file

@ -9,7 +9,8 @@ import {
File,
findPort,
getBrowserBodyText,
getReactErrorOverlayContent,
getRedboxHeader,
hasRedbox,
initNextServerScript,
killApp,
launchApp,
@ -569,7 +570,8 @@ const runTests = (dev = false, looseMode = false) => {
// we need to reload the page to trigger getStaticProps
await browser.refresh()
const errOverlayContent = await getReactErrorOverlayContent(browser)
expect(await hasRedbox(browser)).toBe(true)
const errOverlayContent = await getRedboxHeader(browser)
await fs.writeFile(indexPage, origContent)
const errorMsg = /oops from getStaticProps/
@ -694,12 +696,13 @@ const runTests = (dev = false, looseMode = false) => {
const browser = await webdriver(appPort, '/non-json/direct')
// FIXME: enable this
// expect(await getReactErrorOverlayContent(browser)).toMatch(
// expect(await getRedboxHeader(browser)).toMatch(
// /Error serializing `.time` returned from `getStaticProps`/
// )
// FIXME: disable this
expect(await getReactErrorOverlayContent(browser)).toMatch(
expect(await hasRedbox(browser)).toBe(true)
expect(await getRedboxHeader(browser)).toMatch(
/Failed to load static props/
)
})
@ -709,12 +712,13 @@ const runTests = (dev = false, looseMode = false) => {
await browser.elementByCss('#non-json').click()
// FIXME: enable this
// expect(await getReactErrorOverlayContent(browser)).toMatch(
// expect(await getRedboxHeader(browser)).toMatch(
// /Error serializing `.time` returned from `getStaticProps`/
// )
// FIXME: disable this
expect(await getReactErrorOverlayContent(browser)).toMatch(
expect(await hasRedbox(browser)).toBe(true)
expect(await getRedboxHeader(browser)).toMatch(
/Failed to load static props/
)
})

View file

@ -1,16 +1,16 @@
/* eslint-env jest */
/* global jasmine */
import fs from 'fs-extra'
import { join } from 'path'
import webdriver from 'next-webdriver'
import {
findPort,
launchApp,
killApp,
check,
findPort,
getBrowserBodyText,
getReactErrorOverlayContent,
getRedboxHeader,
killApp,
launchApp,
} from 'next-test-utils'
import webdriver from 'next-webdriver'
import { join } from 'path'
jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000 * 60 * 2
const appDir = join(__dirname, '..')
@ -57,7 +57,8 @@ describe('TypeScript HMR', () => {
})
})
it('should recover from a type error', async () => {
// old behavior:
it.skip('should recover from a type error', async () => {
let browser
const pagePath = join(appDir, 'pages/type-error-recover.tsx')
const origContent = await fs.readFile(pagePath, 'utf8')
@ -67,7 +68,7 @@ describe('TypeScript HMR', () => {
await fs.writeFile(pagePath, errContent)
await check(
() => getReactErrorOverlayContent(browser),
() => getRedboxHeader(browser),
/Type 'Element' is not assignable to type 'boolean'/
)
@ -81,4 +82,33 @@ describe('TypeScript HMR', () => {
await fs.writeFile(pagePath, origContent)
}
})
it('should ignore type errors in development', async () => {
let browser
const pagePath = join(appDir, 'pages/type-error-recover.tsx')
const origContent = await fs.readFile(pagePath, 'utf8')
try {
browser = await webdriver(appPort, '/type-error-recover')
const errContent = origContent.replace(
'() => <p>Hello world</p>',
'(): boolean => <p>hello with error</p>'
)
await fs.writeFile(pagePath, errContent)
const res = await check(
async () => {
const html = await browser.eval(
'document.querySelector("p").innerText'
)
return html.match(/hello with error/) ? 'success' : 'fail'
},
/success/,
false
)
expect(res).toBe(true)
} finally {
if (browser) browser.close()
await fs.writeFile(pagePath, origContent)
}
})
})

View file

@ -16,7 +16,9 @@ const appDir = join(__dirname, '..')
const nextConfigFile = new File(join(appDir, 'next.config.js'))
describe('TypeScript with error handling options', () => {
for (const ignoreDevErrors of [false, true]) {
// Dev can no longer show errors (for now), logbox will cover this in the
// future.
for (const ignoreDevErrors of [/*false,*/ true]) {
for (const ignoreBuildErrors of [false, true]) {
describe(`ignoreDevErrors: ${ignoreDevErrors}, ignoreBuildErrors: ${ignoreBuildErrors}`, () => {
beforeAll(() => {

View file

@ -51,7 +51,8 @@ describe('TypeScript Features', () => {
expect($('#imported-value').text()).toBe('OK')
})
it('should report type checking to stdout', async () => {
// old behavior:
it.skip('should report type checking to stdout', async () => {
expect(output).toContain('waiting for typecheck results...')
})

View file

@ -1,17 +1,18 @@
/* eslint-env jest */
/* global jasmine */
import webdriver from 'next-webdriver'
import { join } from 'path'
import {
getReactErrorOverlayContent,
nextServer,
launchApp,
findPort,
getRedboxHeader,
hasRedbox,
killApp,
launchApp,
nextBuild,
nextServer,
startApp,
stopApp,
} from 'next-test-utils'
import webdriver from 'next-webdriver'
import { join } from 'path'
jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000 * 60 * 5
@ -104,7 +105,8 @@ describe('withRouter SSR', () => {
it('should show an error when trying to use router methods during SSR', async () => {
const browser = await webdriver(port, '/router-method-ssr')
expect(await getReactErrorOverlayContent(browser)).toMatch(
expect(await hasRedbox(browser)).toBe(true)
expect(await getRedboxHeader(browser)).toMatch(
`No router instance found. you should only use "next/router" inside the client side of your app. https://err.sh/`
)
await browser.close()

View file

@ -394,36 +394,65 @@ export class File {
}
}
// react-error-overlay uses an iframe so we have to read the contents from the frame
export async function getReactErrorOverlayContent(browser) {
let found = false
setTimeout(() => {
if (found) {
return
}
console.error('TIMED OUT CHECK FOR IFRAME')
throw new Error('TIMED OUT CHECK FOR IFRAME')
}, 1000 * 30)
while (!found) {
try {
await browser.waitForElementByCss('iframe', 10000)
const hasIframe = await browser.hasElementByCssSelector('iframe')
if (!hasIframe) {
throw new Error('Waiting for iframe')
}
found = true
return browser.eval(
`document.querySelector('iframe').contentWindow.document.body.innerHTML`
)
} catch (ex) {
await waitFor(1000)
}
export async function evaluate(browser, input) {
if (typeof input === 'function') {
const result = await browser.executeScript(input)
await new Promise(resolve => setTimeout(resolve, 30))
return result
} else {
throw new Error(`You must pass a function to be evaluated in the browser.`)
}
return browser.eval(
`document.querySelector('iframe').contentWindow.document.body.innerHTML`
)
}
export async function hasRedbox(browser, expected = true) {
let attempts = 30
do {
const has = await evaluate(browser, () => {
return Boolean(
[].slice
.call(document.querySelectorAll('nextjs-portal'))
.find(p =>
p.shadowRoot.querySelector(
'#nextjs__container_errors_label, #nextjs__container_build_error_label'
)
)
)
})
if (has) {
return true
}
if (--attempts < 0) {
break
}
await new Promise(resolve => setTimeout(resolve, 1000))
} while (expected)
return false
}
export async function getRedboxHeader(browser) {
return evaluate(browser, () => {
const portal = [].slice
.call(document.querySelectorAll('nextjs-portal'))
.find(p => p.shadowRoot.querySelector('[data-nextjs-dialog-header'))
const root = portal.shadowRoot
return root.querySelector('[data-nextjs-dialog-header]').innerText
})
}
export async function getRedboxSource(browser) {
return evaluate(browser, () => {
const portal = [].slice
.call(document.querySelectorAll('nextjs-portal'))
.find(p =>
p.shadowRoot.querySelector(
'#nextjs__container_errors_label, #nextjs__container_build_error_label'
)
)
const root = portal.shadowRoot
return root.querySelector('[data-nextjs-codeframe], [data-nextjs-terminal]')
.innerText
})
}
export function getBrowserBodyText(browser) {