Add handling for auto installing TypeScript deps and HMRing tsconfig (#39838)

This adds handling for auto-detecting TypeScript being added to a project and installing the necessary dependencies instead of printing the command and requiring the user run the command. We have been testing the auto install handling for a while now with the `next lint` command and it has worked out pretty well. 

This also adds HMR handling for `jsconfig.json`/`tsconfig.json` in development so if the `baseURL` or `paths` configs are modified it doesn't require a dev server restart for the updates to be picked up. 

This also corrects our required dependencies detection as previously an incorrect `paths: []` value was being passed to `require.resolve` causing it to fail in specific situations.

Closes: https://github.com/vercel/next.js/issues/36201

### `next build` before

https://user-images.githubusercontent.com/22380829/186039578-75f8c128-a13d-4e07-b5da-13bf186ee011.mp4

### `next build` after


https://user-images.githubusercontent.com/22380829/186039662-57af22a4-da5c-4ede-94ea-96541a032cca.mp4

### `next dev` automatic setup and HMR handling

https://user-images.githubusercontent.com/22380829/186039678-d78469ef-d00b-4ee6-8163-a4706394a7b4.mp4


## Bug

- [x] Related issues linked using `fixes #number`
- [x] Integration tests added
- [x] Errors have helpful link attached, see `contributing.md`
This commit is contained in:
JJ Kasper 2022-08-23 13:16:47 -05:00 committed by GitHub
parent 8dfab19d6e
commit ec25b4742b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 885 additions and 196 deletions

View file

@ -95,7 +95,14 @@ export function install(
*/
const child = spawn(command, args, {
stdio: 'inherit',
env: { ...process.env, ADBLOCK: '1', DISABLE_OPENCOLLECTIVE: '1' },
env: {
...process.env,
ADBLOCK: '1',
// we set NODE_ENV to development as pnpm skips dev
// dependencies when production
NODE_ENV: 'development',
DISABLE_OPENCOLLECTIVE: '1',
},
})
child.on('close', (code) => {
if (code !== 0) {

View file

@ -187,14 +187,14 @@ function verifyTypeScriptSetup(
typeCheckWorker.getStderr().pipe(process.stderr)
return typeCheckWorker
.verifyTypeScriptSetup(
.verifyTypeScriptSetup({
dir,
intentDirs,
typeCheckPreflight,
tsconfigPath,
disableStaticImages,
cacheDir
)
cacheDir,
})
.then((result) => {
typeCheckWorker.end()
return result

View file

@ -5,6 +5,7 @@ import * as Log from './output/log'
import { getTypeScriptConfiguration } from '../lib/typescript/getTypeScriptConfiguration'
import { readFileSync } from 'fs'
import isError from '../lib/is-error'
import { hasNecessaryDependencies } from '../lib/has-necessary-dependencies'
let TSCONFIG_WARNED = false
@ -42,7 +43,14 @@ export default async function loadJsConfig(
) {
let typeScriptPath: string | undefined
try {
typeScriptPath = require.resolve('typescript', { paths: [dir] })
const deps = await hasNecessaryDependencies(dir, [
{
pkg: 'typescript',
file: 'typescript/lib/typescript.js',
exportsRestrict: true,
},
])
typeScriptPath = deps.resolved.get('typescript')
} catch (_) {}
const tsConfigPath = path.join(dir, config.typescript.tsconfigPath)
const useTypeScript = Boolean(

View file

@ -531,10 +531,7 @@ export default async function getBaseWebpackConfig(
const isClient = compilerType === COMPILER_NAMES.client
const isEdgeServer = compilerType === COMPILER_NAMES.edgeServer
const isNodeServer = compilerType === COMPILER_NAMES.server
const { useTypeScript, jsConfig, resolvedBaseUrl } = await loadJsConfig(
dir,
config
)
const { jsConfig, resolvedBaseUrl } = await loadJsConfig(dir, config)
const supportedBrowsers = await getSupportedBrowsers(dir, dev, config)
@ -832,22 +829,8 @@ export default async function getBaseWebpackConfig(
const resolveConfig = {
// Disable .mjs for node_modules bundling
extensions: isNodeServer
? [
'.js',
'.mjs',
...(useTypeScript ? ['.tsx', '.ts'] : []),
'.jsx',
'.json',
'.wasm',
]
: [
'.mjs',
'.js',
...(useTypeScript ? ['.tsx', '.ts'] : []),
'.jsx',
'.json',
'.wasm',
],
? ['.js', '.mjs', '.tsx', '.ts', '.jsx', '.json', '.wasm']
: ['.mjs', '.js', '.tsx', '.ts', '.jsx', '.json', '.wasm'],
modules: [
'node_modules',
...nodePathList, // Support for NODE_PATH environment variable
@ -1831,11 +1814,14 @@ export default async function getBaseWebpackConfig(
webpackConfig.resolve?.modules?.push(resolvedBaseUrl)
}
if (jsConfig?.compilerOptions?.paths && resolvedBaseUrl) {
webpackConfig.resolve?.plugins?.unshift(
new JsConfigPathsPlugin(jsConfig.compilerOptions.paths, resolvedBaseUrl)
// allows add JsConfigPathsPlugin to allow hot-reloading
// if the config is added/removed
webpackConfig.resolve?.plugins?.unshift(
new JsConfigPathsPlugin(
jsConfig?.compilerOptions?.paths || {},
resolvedBaseUrl || dir
)
}
)
const webpack5Config = webpackConfig as webpack.Configuration

View file

@ -169,23 +169,16 @@ type Paths = { [match: string]: string[] }
export class JsConfigPathsPlugin implements webpack.ResolvePluginInstance {
paths: Paths
resolvedBaseUrl: string
jsConfigPlugin: true
constructor(paths: Paths, resolvedBaseUrl: string) {
this.paths = paths
this.resolvedBaseUrl = resolvedBaseUrl
this.jsConfigPlugin = true
log('tsconfig.json or jsconfig.json paths: %O', paths)
log('resolved baseUrl: %s', resolvedBaseUrl)
}
apply(resolver: any) {
const paths = this.paths
const pathsKeys = Object.keys(paths)
// If no aliases are added bail out
if (pathsKeys.length === 0) {
log('paths are empty, bailing out')
return
}
const baseDirectory = this.resolvedBaseUrl
const target = resolver.ensureHook('resolve')
resolver
.getHook('described-resolve')
@ -196,6 +189,15 @@ export class JsConfigPathsPlugin implements webpack.ResolvePluginInstance {
resolveContext: any,
callback: (err?: any, result?: any) => void
) => {
const paths = this.paths
const pathsKeys = Object.keys(paths)
// If no aliases are added bail out
if (pathsKeys.length === 0) {
log('paths are empty, bailing out')
return callback()
}
const moduleName = request.request
// Exclude node_modules from paths support (speeds up resolving)
@ -246,7 +248,7 @@ export class JsConfigPathsPlugin implements webpack.ResolvePluginInstance {
// try next path candidate
return pathCallback()
}
const candidate = path.join(baseDirectory, curPath)
const candidate = path.join(this.resolvedBaseUrl, curPath)
const obj = Object.assign({}, request, {
request: candidate,
})

View file

@ -1,5 +1,7 @@
import { existsSync } from 'fs'
import { join, relative } from 'path'
import { promises as fs } from 'fs'
import { fileExists } from './file-exists'
import { resolveFrom } from './resolve-from'
import { dirname, join, relative } from 'path'
export interface MissingDependency {
file: string
@ -17,31 +19,36 @@ export async function hasNecessaryDependencies(
requiredPackages: MissingDependency[]
): Promise<NecessaryDependencies> {
let resolutions = new Map<string, string>()
const missingPackages = requiredPackages.filter((p) => {
try {
if (p.exportsRestrict) {
const pkgPath = require.resolve(`${p.pkg}/package.json`, {
paths: [baseDir],
})
const fileNameToVerify = relative(p.pkg, p.file)
if (fileNameToVerify) {
const fileToVerify = join(pkgPath, '..', fileNameToVerify)
if (existsSync(fileToVerify)) {
resolutions.set(p.pkg, join(pkgPath, '..'))
const missingPackages: MissingDependency[] = []
await Promise.all(
requiredPackages.map(async (p) => {
try {
const pkgPath = await fs.realpath(
resolveFrom(baseDir, `${p.pkg}/package.json`)
)
const pkgDir = dirname(pkgPath)
if (p.exportsRestrict) {
const fileNameToVerify = relative(p.pkg, p.file)
if (fileNameToVerify) {
const fileToVerify = join(pkgDir, fileNameToVerify)
if (await fileExists(fileToVerify)) {
resolutions.set(p.pkg, fileToVerify)
} else {
return missingPackages.push(p)
}
} else {
return true
resolutions.set(p.pkg, pkgPath)
}
} else {
resolutions.set(p.pkg, pkgPath)
resolutions.set(p.pkg, resolveFrom(baseDir, p.file))
}
} else {
resolutions.set(p.pkg, require.resolve(p.file, { paths: [baseDir] }))
} catch (_) {
return missingPackages.push(p)
}
return false
} catch (_) {
return true
}
})
})
)
return {
resolved: resolutions,

View file

@ -95,7 +95,14 @@ export function install(
*/
const child = spawn(command, args, {
stdio: 'inherit',
env: { ...process.env, ADBLOCK: '1', DISABLE_OPENCOLLECTIVE: '1' },
env: {
...process.env,
ADBLOCK: '1',
// we set NODE_ENV to development as pnpm skips dev
// dependencies when production
NODE_ENV: 'development',
DISABLE_OPENCOLLECTIVE: '1',
},
})
child.on('close', (code) => {
if (code !== 0) {

View file

@ -0,0 +1,55 @@
// source: https://github.com/sindresorhus/resolve-from
import fs from 'fs'
import path from 'path'
import isError from './is-error'
const Module = require('module')
export const resolveFrom = (
fromDirectory: string,
moduleId: string,
silent?: boolean
) => {
if (typeof fromDirectory !== 'string') {
throw new TypeError(
`Expected \`fromDir\` to be of type \`string\`, got \`${typeof fromDirectory}\``
)
}
if (typeof moduleId !== 'string') {
throw new TypeError(
`Expected \`moduleId\` to be of type \`string\`, got \`${typeof moduleId}\``
)
}
try {
fromDirectory = fs.realpathSync(fromDirectory)
} catch (error: unknown) {
if (isError(error) && error.code === 'ENOENT') {
fromDirectory = path.resolve(fromDirectory)
} else if (silent) {
return
} else {
throw error
}
}
const fromFile = path.join(fromDirectory, 'noop.js')
const resolveFileName = () =>
Module._resolveFilename(moduleId, {
id: fromFile,
filename: fromFile,
paths: Module._nodeModulePaths(fromDirectory),
})
if (silent) {
try {
return resolveFileName()
} catch (error) {
return
}
}
return resolveFileName()
}

View file

@ -1,43 +0,0 @@
import chalk from 'next/dist/compiled/chalk'
import { getOxfordCommaList } from '../oxford-comma-list'
import { MissingDependency } from '../has-necessary-dependencies'
import { FatalError } from '../fatal-error'
import { getPkgManager } from '../helpers/get-pkg-manager'
export async function missingDepsError(
dir: string,
missingPackages: MissingDependency[]
) {
const packagesHuman = getOxfordCommaList(missingPackages.map((p) => p.pkg))
const packagesCli = missingPackages.map((p) => p.pkg).join(' ')
const packageManager = getPkgManager(dir)
const removalMsg =
'\n\n' +
chalk.bold(
'If you are not trying to use TypeScript, please remove the ' +
chalk.cyan('tsconfig.json') +
' file from your package root (and any TypeScript files in your pages directory).'
)
throw new FatalError(
chalk.bold.red(
`It looks like you're trying to use TypeScript but do not have the required package(s) installed.`
) +
'\n\n' +
chalk.bold(`Please install ${chalk.bold(packagesHuman)} by running:`) +
'\n\n' +
`\t${chalk.bold.cyan(
(packageManager === 'yarn'
? 'yarn add --dev'
: packageManager === 'pnpm'
? 'pnpm install --save-dev'
: 'npm install --save-dev') +
' ' +
packagesCli
)}` +
removalMsg +
'\n'
)
}

View file

@ -13,10 +13,14 @@ import { getTypeScriptIntent } from './typescript/getTypeScriptIntent'
import { TypeCheckResult } from './typescript/runTypeCheck'
import { writeAppTypeDeclarations } from './typescript/writeAppTypeDeclarations'
import { writeConfigurationDefaults } from './typescript/writeConfigurationDefaults'
import { missingDepsError } from './typescript/missingDependencyError'
import { installDependencies } from './install-dependencies'
const requiredPackages = [
{ file: 'typescript', pkg: 'typescript', exportsRestrict: false },
{
file: 'typescript/lib/typescript.js',
pkg: 'typescript',
exportsRestrict: true,
},
{
file: '@types/react/index.d.ts',
pkg: '@types/react',
@ -25,18 +29,25 @@ const requiredPackages = [
{
file: '@types/node/index.d.ts',
pkg: '@types/node',
exportsRestrict: false,
exportsRestrict: true,
},
]
export async function verifyTypeScriptSetup(
dir: string,
intentDirs: string[],
typeCheckPreflight: boolean,
tsconfigPath: string,
disableStaticImages: boolean,
export async function verifyTypeScriptSetup({
dir,
cacheDir,
intentDirs,
tsconfigPath,
typeCheckPreflight,
disableStaticImages,
}: {
dir: string
cacheDir?: string
): Promise<{ result?: TypeCheckResult; version: string | null }> {
tsconfigPath: string
intentDirs: string[]
typeCheckPreflight: boolean
disableStaticImages: boolean
}): Promise<{ result?: TypeCheckResult; version: string | null }> {
const resolvedTsConfigPath = path.join(dir, tsconfigPath)
try {
@ -47,13 +58,36 @@ export async function verifyTypeScriptSetup(
}
// Ensure TypeScript and necessary `@types/*` are installed:
const deps: NecessaryDependencies = await hasNecessaryDependencies(
let deps: NecessaryDependencies = await hasNecessaryDependencies(
dir,
requiredPackages
)
if (deps.missing?.length > 0) {
await missingDepsError(dir, deps.missing)
console.log(
chalk.bold.yellow(
`It looks like you're trying to use TypeScript but do not have the required package(s) installed.`
) +
'\n' +
'Installing dependencies' +
'\n\n' +
chalk.bold(
'If you are not trying to use TypeScript, please remove the ' +
chalk.cyan('tsconfig.json') +
' file from your package root (and any TypeScript files in your pages directory).'
) +
'\n'
)
await installDependencies(dir, deps.missing, true).catch((err) => {
if (err && typeof err === 'object' && 'command' in err) {
console.error(
`Failed to install required TypeScript dependencies, please install them manually to continue:\n` +
(err as any).command
)
}
throw err
})
deps = await hasNecessaryDependencies(dir, requiredPackages)
}
// Load TypeScript after we're sure it exists:

View file

@ -46,7 +46,10 @@ import { setGlobal } from '../../trace'
import HotReloader from './hot-reloader'
import { findPageFile } from '../lib/find-page-file'
import { getNodeOptionsWithoutInspect } from '../lib/utils'
import { withCoalescedInvoke } from '../../lib/coalesced-function'
import {
UnwrapPromise,
withCoalescedInvoke,
} from '../../lib/coalesced-function'
import { loadDefaultErrorComponents } from '../load-components'
import { DecodeError, MiddlewareNotFoundError } from '../../shared/lib/utils'
import {
@ -73,6 +76,7 @@ import {
NestedMiddlewareError,
} from '../../build/utils'
import { getDefineEnv } from '../../build/webpack-config'
import loadJsConfig from '../../build/load-jsconfig'
// Load ReactDevOverlay only when needed
let ReactDevOverlayImpl: React.FunctionComponent
@ -104,6 +108,8 @@ export default class DevServer extends Server {
private actualMiddlewareFile?: string
private middleware?: RoutingItem
private edgeFunctions?: RoutingItem[]
private verifyingTypeScript?: boolean
private usingTypeScript?: boolean
protected staticPathsWorker?: { [key: string]: any } & {
loadStaticPaths: typeof import('./static-paths-worker').loadStaticPaths
@ -287,8 +293,17 @@ export default class DevServer extends Server {
].map((file) => pathJoin(this.dir, file))
files.push(...envFiles)
// tsconfig/jsonfig paths hot-reloading
const tsconfigPaths = [
pathJoin(this.dir, 'tsconfig.json'),
pathJoin(this.dir, 'jsconfig.json'),
]
files.push(...tsconfigPaths)
wp.watch({ directories: [this.dir], startTime: 0 })
const envFileTimes = new Map()
const fileWatchTimes = new Map()
let enabledTypeScript = this.usingTypeScript
wp.on('aggregated', async () => {
let middlewareMatcher: RegExp | undefined
@ -297,6 +312,7 @@ export default class DevServer extends Server {
const appPaths: Record<string, string> = {}
const edgeRoutesSet = new Set<string>()
let envChange = false
let tsconfigChange = false
for (const [fileName, meta] of knownFiles) {
if (
@ -306,14 +322,24 @@ export default class DevServer extends Server {
continue
}
const watchTime = fileWatchTimes.get(fileName)
const watchTimeChange = watchTime && watchTime !== meta?.timestamp
fileWatchTimes.set(fileName, meta.timestamp)
if (envFiles.includes(fileName)) {
if (
envFileTimes.get(fileName) &&
envFileTimes.get(fileName) !== meta.timestamp
) {
if (watchTimeChange) {
envChange = true
}
envFileTimes.set(fileName, meta.timestamp)
continue
}
if (tsconfigPaths.includes(fileName)) {
if (fileName.endsWith('tsconfig.json')) {
enabledTypeScript = true
}
if (watchTimeChange) {
tsconfigChange = true
}
continue
}
@ -350,6 +376,10 @@ export default class DevServer extends Server {
continue
}
if (fileName.endsWith('.ts') || fileName.endsWith('.tsx')) {
enabledTypeScript = true
}
let pageName = absolutePathToPage(fileName, {
pagesDir: isAppPath ? this.appDir! : this.pagesDir,
extensions: this.nextConfig.pageExtensions,
@ -392,8 +422,31 @@ export default class DevServer extends Server {
})
}
if (envChange) {
this.loadEnvConfig({ dev: true, forceReload: true })
if (!this.usingTypeScript && enabledTypeScript) {
// we tolerate the error here as this is best effort
// and the manual install command will be shown
await this.verifyTypeScript()
.then(() => {
tsconfigChange = true
})
.catch(() => {})
}
if (envChange || tsconfigChange) {
if (envChange) {
this.loadEnvConfig({ dev: true, forceReload: true })
}
let tsconfigResult:
| UnwrapPromise<ReturnType<typeof loadJsConfig>>
| undefined
if (tsconfigChange) {
try {
tsconfigResult = await loadJsConfig(this.dir, this.nextConfig)
} catch (_) {
/* do we want to log if there are syntax errors in tsconfig while editing? */
}
}
this.hotReloader?.activeConfigs?.forEach((config, idx) => {
const isClient = idx === 0
@ -404,34 +457,69 @@ export default class DevServer extends Server {
this.customRoutes.rewrites.beforeFiles.length > 0 ||
this.customRoutes.rewrites.fallback.length > 0
config.plugins?.forEach((plugin: any) => {
// we look for the DefinePlugin definitions so we can
// update them on the active compilers
if (
plugin &&
typeof plugin.definitions === 'object' &&
plugin.definitions.__NEXT_DEFINE_ENV
) {
const newDefine = getDefineEnv({
dev: true,
config: this.nextConfig,
distDir: this.distDir,
isClient,
hasRewrites,
hasReactRoot: this.hotReloader?.hasReactRoot,
isNodeServer,
isEdgeServer,
hasServerComponents: this.hotReloader?.hasServerComponents,
})
if (tsconfigChange) {
config.resolve?.plugins?.forEach((plugin: any) => {
// look for the JsConfigPathsPlugin and update with
// the latest paths/baseUrl config
if (plugin && plugin.jsConfigPlugin && tsconfigResult) {
const { resolvedBaseUrl, jsConfig } = tsconfigResult
const currentResolvedBaseUrl = plugin.resolvedBaseUrl
const resolvedUrlIndex = config.resolve?.modules?.findIndex(
(item) => item === currentResolvedBaseUrl
)
Object.keys(plugin.definitions).forEach((key) => {
if (!(key in newDefine)) {
delete plugin.definitions[key]
if (
resolvedBaseUrl &&
resolvedBaseUrl !== currentResolvedBaseUrl
) {
// remove old baseUrl and add new one
if (resolvedUrlIndex && resolvedUrlIndex > -1) {
config.resolve?.modules?.splice(resolvedUrlIndex, 1)
}
config.resolve?.modules?.push(resolvedBaseUrl)
}
})
Object.assign(plugin.definitions, newDefine)
}
})
if (jsConfig?.compilerOptions?.paths && resolvedBaseUrl) {
Object.keys(plugin.paths).forEach((key) => {
delete plugin.paths[key]
})
Object.assign(plugin.paths, jsConfig.compilerOptions.paths)
plugin.resolvedBaseUrl = resolvedBaseUrl
}
}
})
}
if (envChange) {
config.plugins?.forEach((plugin: any) => {
// we look for the DefinePlugin definitions so we can
// update them on the active compilers
if (
plugin &&
typeof plugin.definitions === 'object' &&
plugin.definitions.__NEXT_DEFINE_ENV
) {
const newDefine = getDefineEnv({
dev: true,
config: this.nextConfig,
distDir: this.distDir,
isClient,
hasRewrites,
hasReactRoot: this.hotReloader?.hasReactRoot,
isNodeServer,
isEdgeServer,
hasServerComponents: this.hotReloader?.hasServerComponents,
})
Object.keys(plugin.definitions).forEach((key) => {
if (!(key in newDefine)) {
delete plugin.definitions[key]
}
})
Object.assign(plugin.definitions, newDefine)
}
})
}
})
this.hotReloader?.invalidate()
}
@ -516,17 +604,33 @@ export default class DevServer extends Server {
this.webpackWatcher = null
}
private async verifyTypeScript() {
if (this.verifyingTypeScript) {
return
}
try {
this.verifyingTypeScript = true
const verifyResult = await verifyTypeScriptSetup({
dir: this.dir,
intentDirs: [this.pagesDir!, this.appDir].filter(Boolean) as string[],
typeCheckPreflight: false,
tsconfigPath: this.nextConfig.typescript.tsconfigPath,
disableStaticImages: this.nextConfig.images.disableStaticImages,
})
if (verifyResult.version) {
this.usingTypeScript = true
}
} finally {
this.verifyingTypeScript = false
}
}
async prepare(): Promise<void> {
setGlobal('distDir', this.distDir)
setGlobal('phase', PHASE_DEVELOPMENT_SERVER)
await verifyTypeScriptSetup(
this.dir,
[this.pagesDir!, this.appDir].filter(Boolean) as string[],
false,
this.nextConfig.typescript.tsconfigPath,
this.nextConfig.images.disableStaticImages
)
await this.verifyTypeScript()
this.customRoutes = await loadCustomRoutes(this.nextConfig)
// reload router

View file

@ -0,0 +1,3 @@
export function Button1(props) {
return <button {...props}>first button</button>
}

View file

@ -0,0 +1,3 @@
export function Button2(props) {
return <button {...props}>second button</button>
}

View file

@ -0,0 +1,3 @@
export function Button2(props) {
return <button {...props}>third button</button>
}

View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"incremental": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"baseUrl": ".",
"paths": {
"@c/*": ["components/*"],
"@lib/*": ["lib/first-lib/*"],
"@mybutton": ["components/button-2.js"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

View file

@ -0,0 +1,3 @@
export const firstData = {
hello: 'world',
}

View file

@ -0,0 +1,3 @@
export const secondData = {
hello: 'again',
}

View file

@ -0,0 +1,13 @@
import { Button1 } from '@c/button-1'
import { Button2 } from '@mybutton'
import { firstData } from '@lib/first-data'
export default function Page(props) {
return (
<>
<Button1 />
<Button2 />
<p id="first-data">{JSON.stringify(firstData)}</p>
</>
)
}

View file

@ -0,0 +1,190 @@
import { createNext, FileRef } from 'e2e-utils'
import { NextInstance } from 'test/lib/next-modes/base'
import {
check,
hasRedbox,
renderViaHTTP,
getRedboxSource,
} from 'next-test-utils'
import cheerio from 'cheerio'
import { join } from 'path'
import webdriver from 'next-webdriver'
import fs from 'fs-extra'
describe('jsconfig-path-reloading', () => {
let next: NextInstance
const tsConfigFile = 'jsconfig.json'
const indexPage = 'pages/index.js'
function runTests({ addAfterStart }: { addAfterStart?: boolean }) {
beforeAll(async () => {
let tsConfigContent = await fs.readFile(
join(__dirname, 'app/jsconfig.json'),
'utf8'
)
next = await createNext({
files: {
components: new FileRef(join(__dirname, 'app/components')),
pages: new FileRef(join(__dirname, 'app/pages')),
lib: new FileRef(join(__dirname, 'app/lib')),
...(addAfterStart
? {}
: {
[tsConfigFile]: tsConfigContent,
}),
},
dependencies: {
typescript: 'latest',
'@types/react': 'latest',
'@types/node': 'latest',
},
})
if (addAfterStart) {
await next.patchFile(tsConfigFile, tsConfigContent)
}
})
afterAll(() => next.destroy())
it('should load with initial paths config correctly', async () => {
const html = await renderViaHTTP(next.url, '/')
const $ = cheerio.load(html)
expect(html).toContain('first button')
expect(html).toContain('second button')
expect($('#first-data').text()).toContain(
JSON.stringify({
hello: 'world',
})
)
})
it('should recover from module not found when paths is updated', async () => {
const indexContent = await next.readFile(indexPage)
const tsconfigContent = await next.readFile(tsConfigFile)
const parsedTsConfig = JSON.parse(tsconfigContent)
const browser = await webdriver(next.url, '/')
try {
const html = await browser.eval('document.documentElement.innerHTML')
expect(html).toContain('first button')
expect(html).toContain('second button')
expect(html).toContain('first-data')
expect(html).not.toContain('second-data')
await next.patchFile(
indexPage,
`import {secondData} from "@lib/second-data"\n${indexContent.replace(
'</p>',
`</p><p id="second-data">{JSON.stringify(secondData)}</p>`
)}`
)
expect(await hasRedbox(browser, true)).toBe(true)
expect(await getRedboxSource(browser)).toContain('"@lib/second-data"')
await next.patchFile(
tsConfigFile,
JSON.stringify(
{
...parsedTsConfig,
compilerOptions: {
...parsedTsConfig.compilerOptions,
paths: {
...parsedTsConfig.compilerOptions.paths,
'@lib/*': ['lib/first-lib/*', 'lib/second-lib/*'],
},
},
},
null,
2
)
)
expect(await hasRedbox(browser, false)).toBe(false)
const html2 = await browser.eval('document.documentElement.innerHTML')
expect(html2).toContain('first button')
expect(html2).toContain('second button')
expect(html2).toContain('first-data')
expect(html2).toContain('second-data')
} finally {
await next.patchFile(indexPage, indexContent)
await next.patchFile(tsConfigFile, tsconfigContent)
await check(async () => {
const html3 = await browser.eval('document.documentElement.innerHTML')
return html3.includes('first-data') && !html3.includes('second-data')
? 'success'
: html3
}, 'success')
}
})
it('should automatically fast refresh content when path is added without error', async () => {
const indexContent = await next.readFile(indexPage)
const tsconfigContent = await next.readFile(tsConfigFile)
const parsedTsConfig = JSON.parse(tsconfigContent)
const browser = await webdriver(next.url, '/')
try {
const html = await browser.eval('document.documentElement.innerHTML')
expect(html).toContain('first button')
expect(html).toContain('second button')
expect(html).toContain('first-data')
await next.patchFile(
tsConfigFile,
JSON.stringify(
{
...parsedTsConfig,
compilerOptions: {
...parsedTsConfig.compilerOptions,
paths: {
...parsedTsConfig.compilerOptions.paths,
'@myotherbutton': ['components/button-3.js'],
},
},
},
null,
2
)
)
await next.patchFile(
indexPage,
indexContent.replace('@mybutton', '@myotherbutton')
)
expect(await hasRedbox(browser, false)).toBe(false)
await check(async () => {
const html2 = await browser.eval('document.documentElement.innerHTML')
expect(html2).toContain('first button')
expect(html2).not.toContain('second button')
expect(html2).toContain('third button')
expect(html2).toContain('first-data')
return 'success'
}, 'success')
} finally {
await next.patchFile(indexPage, indexContent)
await next.patchFile(tsConfigFile, tsconfigContent)
await check(async () => {
const html3 = await browser.eval('document.documentElement.innerHTML')
return html3.includes('first button') &&
!html3.includes('third button')
? 'success'
: html3
}, 'success')
}
})
}
describe('jsconfig', () => {
runTests({})
})
describe('jsconfig added after starting dev', () => {
runTests({ addAfterStart: true })
})
})

View file

@ -0,0 +1,3 @@
export function Button1(props) {
return <button {...props}>first button</button>
}

View file

@ -0,0 +1,3 @@
export function Button2(props) {
return <button {...props}>second button</button>
}

View file

@ -0,0 +1,3 @@
export function Button2(props) {
return <button {...props}>third button</button>
}

View file

@ -0,0 +1,3 @@
export const firstData = {
hello: 'world',
}

View file

@ -0,0 +1,3 @@
export const secondData = {
hello: 'again',
}

View file

@ -0,0 +1,13 @@
import { Button1 } from '@c/button-1'
import { Button2 } from '@mybutton'
import { firstData } from '@lib/first-data'
export default function Page(props) {
return (
<>
<Button1 />
<Button2 />
<p id="first-data">{JSON.stringify(firstData)}</p>
</>
)
}

View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"incremental": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"baseUrl": ".",
"paths": {
"@c/*": ["components/*"],
"@lib/*": ["lib/first-lib/*"],
"@mybutton": ["components/button-2.tsx"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

View file

@ -0,0 +1,190 @@
import { createNext, FileRef } from 'e2e-utils'
import { NextInstance } from 'test/lib/next-modes/base'
import {
check,
hasRedbox,
renderViaHTTP,
getRedboxSource,
} from 'next-test-utils'
import cheerio from 'cheerio'
import { join } from 'path'
import webdriver from 'next-webdriver'
import fs from 'fs-extra'
describe('tsconfig-path-reloading', () => {
let next: NextInstance
const tsConfigFile = 'tsconfig.json'
const indexPage = 'pages/index.tsx'
function runTests({ addAfterStart }: { addAfterStart?: boolean }) {
beforeAll(async () => {
let tsConfigContent = await fs.readFile(
join(__dirname, 'app/tsconfig.json'),
'utf8'
)
next = await createNext({
files: {
components: new FileRef(join(__dirname, 'app/components')),
pages: new FileRef(join(__dirname, 'app/pages')),
lib: new FileRef(join(__dirname, 'app/lib')),
...(addAfterStart
? {}
: {
[tsConfigFile]: tsConfigContent,
}),
},
dependencies: {
typescript: 'latest',
'@types/react': 'latest',
'@types/node': 'latest',
},
})
if (addAfterStart) {
await next.patchFile(tsConfigFile, tsConfigContent)
}
})
afterAll(() => next.destroy())
it('should load with initial paths config correctly', async () => {
const html = await renderViaHTTP(next.url, '/')
const $ = cheerio.load(html)
expect(html).toContain('first button')
expect(html).toContain('second button')
expect($('#first-data').text()).toContain(
JSON.stringify({
hello: 'world',
})
)
})
it('should recover from module not found when paths is updated', async () => {
const indexContent = await next.readFile(indexPage)
const tsconfigContent = await next.readFile(tsConfigFile)
const parsedTsConfig = JSON.parse(tsconfigContent)
const browser = await webdriver(next.url, '/')
try {
const html = await browser.eval('document.documentElement.innerHTML')
expect(html).toContain('first button')
expect(html).toContain('second button')
expect(html).toContain('first-data')
expect(html).not.toContain('second-data')
await next.patchFile(
indexPage,
`import {secondData} from "@lib/second-data"\n${indexContent.replace(
'</p>',
`</p><p id="second-data">{JSON.stringify(secondData)}</p>`
)}`
)
expect(await hasRedbox(browser, true)).toBe(true)
expect(await getRedboxSource(browser)).toContain('"@lib/second-data"')
await next.patchFile(
tsConfigFile,
JSON.stringify(
{
...parsedTsConfig,
compilerOptions: {
...parsedTsConfig.compilerOptions,
paths: {
...parsedTsConfig.compilerOptions.paths,
'@lib/*': ['lib/first-lib/*', 'lib/second-lib/*'],
},
},
},
null,
2
)
)
expect(await hasRedbox(browser, false)).toBe(false)
const html2 = await browser.eval('document.documentElement.innerHTML')
expect(html2).toContain('first button')
expect(html2).toContain('second button')
expect(html2).toContain('first-data')
expect(html2).toContain('second-data')
} finally {
await next.patchFile(indexPage, indexContent)
await next.patchFile(tsConfigFile, tsconfigContent)
await check(async () => {
const html3 = await browser.eval('document.documentElement.innerHTML')
return html3.includes('first-data') && !html3.includes('second-data')
? 'success'
: html3
}, 'success')
}
})
it('should automatically fast refresh content when path is added without error', async () => {
const indexContent = await next.readFile(indexPage)
const tsconfigContent = await next.readFile(tsConfigFile)
const parsedTsConfig = JSON.parse(tsconfigContent)
const browser = await webdriver(next.url, '/')
try {
const html = await browser.eval('document.documentElement.innerHTML')
expect(html).toContain('first button')
expect(html).toContain('second button')
expect(html).toContain('first-data')
await next.patchFile(
tsConfigFile,
JSON.stringify(
{
...parsedTsConfig,
compilerOptions: {
...parsedTsConfig.compilerOptions,
paths: {
...parsedTsConfig.compilerOptions.paths,
'@myotherbutton': ['components/button-3.tsx'],
},
},
},
null,
2
)
)
await next.patchFile(
indexPage,
indexContent.replace('@mybutton', '@myotherbutton')
)
expect(await hasRedbox(browser, false)).toBe(false)
await check(async () => {
const html2 = await browser.eval('document.documentElement.innerHTML')
expect(html2).toContain('first button')
expect(html2).not.toContain('second button')
expect(html2).toContain('third button')
expect(html2).toContain('first-data')
return 'success'
}, 'success')
} finally {
await next.patchFile(indexPage, indexContent)
await next.patchFile(tsConfigFile, tsconfigContent)
await check(async () => {
const html3 = await browser.eval('document.documentElement.innerHTML')
return html3.includes('first button') &&
!html3.includes('third button')
? 'success'
: html3
}, 'success')
}
})
}
describe('tsconfig', () => {
runTests({})
})
describe('tsconfig added after starting dev', () => {
runTests({ addAfterStart: true })
})
})

View file

@ -0,0 +1,61 @@
import { createNext } from 'e2e-utils'
import { NextInstance } from 'test/lib/next-modes/base'
import { check, renderViaHTTP } from 'next-test-utils'
import webdriver from 'next-webdriver'
// @ts-expect-error missing types
import stripAnsi from 'strip-ansi'
describe('typescript-auto-install', () => {
let next: NextInstance
beforeAll(async () => {
next = await createNext({
files: {
'pages/index.js': `
export default function Page() {
return <p>hello world</p>
}
`,
},
startCommand: 'yarn next dev',
installCommand: 'yarn',
dependencies: {},
})
})
afterAll(() => next.destroy())
it('should work', async () => {
const html = await renderViaHTTP(next.url, '/')
expect(html).toContain('hello world')
})
it('should detect TypeScript being added and auto setup', async () => {
const browser = await webdriver(next.url, '/')
const pageContent = await next.readFile('pages/index.js')
await check(
() => browser.eval('document.documentElement.innerHTML'),
/hello world/
)
await next.renameFile('pages/index.js', 'pages/index.tsx')
await check(
() => stripAnsi(next.cliOutput),
/We detected TypeScript in your project and created a tsconfig\.json file for you/i
)
await check(
() => browser.eval('document.documentElement.innerHTML'),
/hello world/
)
await next.patchFile(
'pages/index.tsx',
pageContent.replace('hello world', 'hello again')
)
await check(
() => browser.eval('document.documentElement.innerHTML'),
/hello again/
)
})
})

View file

@ -1,5 +0,0 @@
const mod = require('../../../../../../node_modules/typescript')
mod.version = '3.8.3'
module.exports = mod

View file

@ -0,0 +1,5 @@
const mod = require('../../../../../../../node_modules/typescript')
mod.version = '3.8.3'
module.exports = mod

View file

@ -1,5 +1,5 @@
{
"name": "typescript",
"version": "3.8.3",
"main": "./index.js"
"main": "./lib/typescript.js"
}

View file

@ -12,7 +12,8 @@
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve"
"jsx": "preserve",
"incremental": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]

View file

@ -4,7 +4,7 @@ import { join } from 'path'
import { nextBuild, findPort, launchApp, killApp } from 'next-test-utils'
const appDir = join(__dirname, '../app')
const tsFile = join(appDir, 'node_modules/typescript/index.js')
const tsFile = join(appDir, 'node_modules/typescript/lib/typescript.js')
describe('Minimum TypeScript Warning', () => {
it('should show warning during next build with old version', async () => {

View file

@ -1,28 +0,0 @@
import { createNext } from 'e2e-utils'
import { NextInstance } from 'test/lib/next-modes/base'
describe('missing-dep-error', () => {
let next: NextInstance
beforeAll(async () => {
next = await createNext({
files: {
'pages/index.tsx': `
export default function Page() {
return <p>hello world</p>
}
`,
},
skipStart: true,
})
})
afterAll(() => next.destroy())
it('should only show error once', async () => {
await next.start().catch(() => {})
expect(
next.cliOutput.match(/It looks like you're trying to use TypeScript/g)
?.length
).toBe(1)
})
})