From ec25b4742b1087e76f2b3378d41a8ab0f6686ca4 Mon Sep 17 00:00:00 2001
From: JJ Kasper
Date: Tue, 23 Aug 2022 13:16:47 -0500
Subject: [PATCH] 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`
---
packages/create-next-app/helpers/install.ts | 9 +-
packages/next/build/index.ts | 6 +-
packages/next/build/load-jsconfig.ts | 10 +-
packages/next/build/webpack-config.ts | 34 +---
.../webpack/plugins/jsconfig-paths-plugin.ts | 24 ++-
.../next/lib/has-necessary-dependencies.ts | 51 +++--
packages/next/lib/helpers/install.ts | 9 +-
packages/next/lib/resolve-from.ts | 55 +++++
.../lib/typescript/missingDependencyError.ts | 43 ----
packages/next/lib/verifyTypeScriptSetup.ts | 58 ++++--
packages/next/server/dev/next-dev-server.ts | 190 ++++++++++++++----
.../app/components/button-1.js | 3 +
.../app/components/button-2.js | 3 +
.../app/components/button-3.js | 3 +
.../jsconfig-path-reloading/app/jsconfig.json | 26 +++
.../app/lib/first-lib/first-data.js | 3 +
.../app/lib/second-lib/second-data.js | 3 +
.../app/pages/index.js | 13 ++
.../jsconfig-path-reloading/index.test.ts | 190 ++++++++++++++++++
.../app/components/button-1.tsx | 3 +
.../app/components/button-2.tsx | 3 +
.../app/components/button-3.tsx | 3 +
.../app/lib/first-lib/first-data.ts | 3 +
.../app/lib/second-lib/second-data.ts | 3 +
.../app/pages/index.tsx | 13 ++
.../tsconfig-path-reloading/app/tsconfig.json | 26 +++
.../tsconfig-path-reloading/index.test.ts | 190 ++++++++++++++++++
.../typescript-auto-install/index.test.ts | 61 ++++++
.../app/node_modules/typescript/index.js | 5 -
.../node_modules/typescript/lib/typescript.js | 5 +
.../app/node_modules/typescript/package.json | 2 +-
.../app/tsconfig.json | 3 +-
.../test/index.test.js | 2 +-
.../missing-dep-error/index.test.ts | 28 ---
34 files changed, 886 insertions(+), 197 deletions(-)
create mode 100644 packages/next/lib/resolve-from.ts
delete mode 100644 packages/next/lib/typescript/missingDependencyError.ts
create mode 100644 test/development/jsconfig-path-reloading/app/components/button-1.js
create mode 100644 test/development/jsconfig-path-reloading/app/components/button-2.js
create mode 100644 test/development/jsconfig-path-reloading/app/components/button-3.js
create mode 100644 test/development/jsconfig-path-reloading/app/jsconfig.json
create mode 100644 test/development/jsconfig-path-reloading/app/lib/first-lib/first-data.js
create mode 100644 test/development/jsconfig-path-reloading/app/lib/second-lib/second-data.js
create mode 100644 test/development/jsconfig-path-reloading/app/pages/index.js
create mode 100644 test/development/jsconfig-path-reloading/index.test.ts
create mode 100644 test/development/tsconfig-path-reloading/app/components/button-1.tsx
create mode 100644 test/development/tsconfig-path-reloading/app/components/button-2.tsx
create mode 100644 test/development/tsconfig-path-reloading/app/components/button-3.tsx
create mode 100644 test/development/tsconfig-path-reloading/app/lib/first-lib/first-data.ts
create mode 100644 test/development/tsconfig-path-reloading/app/lib/second-lib/second-data.ts
create mode 100644 test/development/tsconfig-path-reloading/app/pages/index.tsx
create mode 100644 test/development/tsconfig-path-reloading/app/tsconfig.json
create mode 100644 test/development/tsconfig-path-reloading/index.test.ts
create mode 100644 test/development/typescript-auto-install/index.test.ts
delete mode 100644 test/integration/typescript-version-warning/app/node_modules/typescript/index.js
create mode 100644 test/integration/typescript-version-warning/app/node_modules/typescript/lib/typescript.js
delete mode 100644 test/production/missing-dep-error/index.test.ts
diff --git a/packages/create-next-app/helpers/install.ts b/packages/create-next-app/helpers/install.ts
index 8a36345297acf..4cac7d9747f5d 100644
--- a/packages/create-next-app/helpers/install.ts
+++ b/packages/create-next-app/helpers/install.ts
@@ -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) {
diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts
index e076535136538..5b1980ef29759 100644
--- a/packages/next/build/index.ts
+++ b/packages/next/build/index.ts
@@ -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
diff --git a/packages/next/build/load-jsconfig.ts b/packages/next/build/load-jsconfig.ts
index aa775327fc942..7dac1bab01bb1 100644
--- a/packages/next/build/load-jsconfig.ts
+++ b/packages/next/build/load-jsconfig.ts
@@ -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(
diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts
index 646fb93e2eca6..581cec0af01ee 100644
--- a/packages/next/build/webpack-config.ts
+++ b/packages/next/build/webpack-config.ts
@@ -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
diff --git a/packages/next/build/webpack/plugins/jsconfig-paths-plugin.ts b/packages/next/build/webpack/plugins/jsconfig-paths-plugin.ts
index 287a07ffb7821..25b73740cdd4c 100644
--- a/packages/next/build/webpack/plugins/jsconfig-paths-plugin.ts
+++ b/packages/next/build/webpack/plugins/jsconfig-paths-plugin.ts
@@ -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,
})
diff --git a/packages/next/lib/has-necessary-dependencies.ts b/packages/next/lib/has-necessary-dependencies.ts
index 10c3bd67f92d3..5b81a859d2acf 100644
--- a/packages/next/lib/has-necessary-dependencies.ts
+++ b/packages/next/lib/has-necessary-dependencies.ts
@@ -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 {
let resolutions = new Map()
- 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,
diff --git a/packages/next/lib/helpers/install.ts b/packages/next/lib/helpers/install.ts
index f6d252a0b43ff..a0108c58b1e4a 100644
--- a/packages/next/lib/helpers/install.ts
+++ b/packages/next/lib/helpers/install.ts
@@ -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) {
diff --git a/packages/next/lib/resolve-from.ts b/packages/next/lib/resolve-from.ts
new file mode 100644
index 0000000000000..503690ab81e1d
--- /dev/null
+++ b/packages/next/lib/resolve-from.ts
@@ -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()
+}
diff --git a/packages/next/lib/typescript/missingDependencyError.ts b/packages/next/lib/typescript/missingDependencyError.ts
deleted file mode 100644
index 49c21d99d07a0..0000000000000
--- a/packages/next/lib/typescript/missingDependencyError.ts
+++ /dev/null
@@ -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'
- )
-}
diff --git a/packages/next/lib/verifyTypeScriptSetup.ts b/packages/next/lib/verifyTypeScriptSetup.ts
index 9062e2e278ba7..9f06333166a31 100644
--- a/packages/next/lib/verifyTypeScriptSetup.ts
+++ b/packages/next/lib/verifyTypeScriptSetup.ts
@@ -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:
diff --git a/packages/next/server/dev/next-dev-server.ts b/packages/next/server/dev/next-dev-server.ts
index 5f538ced0a2e9..799928af22db9 100644
--- a/packages/next/server/dev/next-dev-server.ts
+++ b/packages/next/server/dev/next-dev-server.ts
@@ -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 = {}
const edgeRoutesSet = new Set()
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>
+ | 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,
- })
-
- Object.keys(plugin.definitions).forEach((key) => {
- if (!(key in newDefine)) {
- delete plugin.definitions[key]
+ 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
+ )
+
+ 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 {
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
diff --git a/test/development/jsconfig-path-reloading/app/components/button-1.js b/test/development/jsconfig-path-reloading/app/components/button-1.js
new file mode 100644
index 0000000000000..296068bbb66d6
--- /dev/null
+++ b/test/development/jsconfig-path-reloading/app/components/button-1.js
@@ -0,0 +1,3 @@
+export function Button1(props) {
+ return
+}
diff --git a/test/development/jsconfig-path-reloading/app/components/button-2.js b/test/development/jsconfig-path-reloading/app/components/button-2.js
new file mode 100644
index 0000000000000..f1208886efac4
--- /dev/null
+++ b/test/development/jsconfig-path-reloading/app/components/button-2.js
@@ -0,0 +1,3 @@
+export function Button2(props) {
+ return
+}
diff --git a/test/development/jsconfig-path-reloading/app/components/button-3.js b/test/development/jsconfig-path-reloading/app/components/button-3.js
new file mode 100644
index 0000000000000..0359c00285d08
--- /dev/null
+++ b/test/development/jsconfig-path-reloading/app/components/button-3.js
@@ -0,0 +1,3 @@
+export function Button2(props) {
+ return
+}
diff --git a/test/development/jsconfig-path-reloading/app/jsconfig.json b/test/development/jsconfig-path-reloading/app/jsconfig.json
new file mode 100644
index 0000000000000..36f88fd7b2e80
--- /dev/null
+++ b/test/development/jsconfig-path-reloading/app/jsconfig.json
@@ -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"]
+}
diff --git a/test/development/jsconfig-path-reloading/app/lib/first-lib/first-data.js b/test/development/jsconfig-path-reloading/app/lib/first-lib/first-data.js
new file mode 100644
index 0000000000000..fec1d03f066ee
--- /dev/null
+++ b/test/development/jsconfig-path-reloading/app/lib/first-lib/first-data.js
@@ -0,0 +1,3 @@
+export const firstData = {
+ hello: 'world',
+}
diff --git a/test/development/jsconfig-path-reloading/app/lib/second-lib/second-data.js b/test/development/jsconfig-path-reloading/app/lib/second-lib/second-data.js
new file mode 100644
index 0000000000000..86498777ff511
--- /dev/null
+++ b/test/development/jsconfig-path-reloading/app/lib/second-lib/second-data.js
@@ -0,0 +1,3 @@
+export const secondData = {
+ hello: 'again',
+}
diff --git a/test/development/jsconfig-path-reloading/app/pages/index.js b/test/development/jsconfig-path-reloading/app/pages/index.js
new file mode 100644
index 0000000000000..859719d413a98
--- /dev/null
+++ b/test/development/jsconfig-path-reloading/app/pages/index.js
@@ -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 (
+ <>
+
+
+ {JSON.stringify(firstData)}
+ >
+ )
+}
diff --git a/test/development/jsconfig-path-reloading/index.test.ts b/test/development/jsconfig-path-reloading/index.test.ts
new file mode 100644
index 0000000000000..a24b0a435a589
--- /dev/null
+++ b/test/development/jsconfig-path-reloading/index.test.ts
@@ -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(
+ '
',
+ `{JSON.stringify(secondData)}
`
+ )}`
+ )
+
+ 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 })
+ })
+})
diff --git a/test/development/tsconfig-path-reloading/app/components/button-1.tsx b/test/development/tsconfig-path-reloading/app/components/button-1.tsx
new file mode 100644
index 0000000000000..296068bbb66d6
--- /dev/null
+++ b/test/development/tsconfig-path-reloading/app/components/button-1.tsx
@@ -0,0 +1,3 @@
+export function Button1(props) {
+ return
+}
diff --git a/test/development/tsconfig-path-reloading/app/components/button-2.tsx b/test/development/tsconfig-path-reloading/app/components/button-2.tsx
new file mode 100644
index 0000000000000..f1208886efac4
--- /dev/null
+++ b/test/development/tsconfig-path-reloading/app/components/button-2.tsx
@@ -0,0 +1,3 @@
+export function Button2(props) {
+ return
+}
diff --git a/test/development/tsconfig-path-reloading/app/components/button-3.tsx b/test/development/tsconfig-path-reloading/app/components/button-3.tsx
new file mode 100644
index 0000000000000..0359c00285d08
--- /dev/null
+++ b/test/development/tsconfig-path-reloading/app/components/button-3.tsx
@@ -0,0 +1,3 @@
+export function Button2(props) {
+ return
+}
diff --git a/test/development/tsconfig-path-reloading/app/lib/first-lib/first-data.ts b/test/development/tsconfig-path-reloading/app/lib/first-lib/first-data.ts
new file mode 100644
index 0000000000000..fec1d03f066ee
--- /dev/null
+++ b/test/development/tsconfig-path-reloading/app/lib/first-lib/first-data.ts
@@ -0,0 +1,3 @@
+export const firstData = {
+ hello: 'world',
+}
diff --git a/test/development/tsconfig-path-reloading/app/lib/second-lib/second-data.ts b/test/development/tsconfig-path-reloading/app/lib/second-lib/second-data.ts
new file mode 100644
index 0000000000000..86498777ff511
--- /dev/null
+++ b/test/development/tsconfig-path-reloading/app/lib/second-lib/second-data.ts
@@ -0,0 +1,3 @@
+export const secondData = {
+ hello: 'again',
+}
diff --git a/test/development/tsconfig-path-reloading/app/pages/index.tsx b/test/development/tsconfig-path-reloading/app/pages/index.tsx
new file mode 100644
index 0000000000000..859719d413a98
--- /dev/null
+++ b/test/development/tsconfig-path-reloading/app/pages/index.tsx
@@ -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 (
+ <>
+
+
+ {JSON.stringify(firstData)}
+ >
+ )
+}
diff --git a/test/development/tsconfig-path-reloading/app/tsconfig.json b/test/development/tsconfig-path-reloading/app/tsconfig.json
new file mode 100644
index 0000000000000..3075aad9c6766
--- /dev/null
+++ b/test/development/tsconfig-path-reloading/app/tsconfig.json
@@ -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"]
+}
diff --git a/test/development/tsconfig-path-reloading/index.test.ts b/test/development/tsconfig-path-reloading/index.test.ts
new file mode 100644
index 0000000000000..ef679796d489d
--- /dev/null
+++ b/test/development/tsconfig-path-reloading/index.test.ts
@@ -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(
+ '',
+ `{JSON.stringify(secondData)}
`
+ )}`
+ )
+
+ 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 })
+ })
+})
diff --git a/test/development/typescript-auto-install/index.test.ts b/test/development/typescript-auto-install/index.test.ts
new file mode 100644
index 0000000000000..4f5cca72d8eab
--- /dev/null
+++ b/test/development/typescript-auto-install/index.test.ts
@@ -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 hello world
+ }
+ `,
+ },
+ 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/
+ )
+ })
+})
diff --git a/test/integration/typescript-version-warning/app/node_modules/typescript/index.js b/test/integration/typescript-version-warning/app/node_modules/typescript/index.js
deleted file mode 100644
index 740559576178f..0000000000000
--- a/test/integration/typescript-version-warning/app/node_modules/typescript/index.js
+++ /dev/null
@@ -1,5 +0,0 @@
-const mod = require('../../../../../../node_modules/typescript')
-
-mod.version = '3.8.3'
-
-module.exports = mod
diff --git a/test/integration/typescript-version-warning/app/node_modules/typescript/lib/typescript.js b/test/integration/typescript-version-warning/app/node_modules/typescript/lib/typescript.js
new file mode 100644
index 0000000000000..ca151aa415238
--- /dev/null
+++ b/test/integration/typescript-version-warning/app/node_modules/typescript/lib/typescript.js
@@ -0,0 +1,5 @@
+const mod = require('../../../../../../../node_modules/typescript')
+
+mod.version = '3.8.3'
+
+module.exports = mod
diff --git a/test/integration/typescript-version-warning/app/node_modules/typescript/package.json b/test/integration/typescript-version-warning/app/node_modules/typescript/package.json
index 5331dd9817e33..69c3d513633ec 100644
--- a/test/integration/typescript-version-warning/app/node_modules/typescript/package.json
+++ b/test/integration/typescript-version-warning/app/node_modules/typescript/package.json
@@ -1,5 +1,5 @@
{
"name": "typescript",
"version": "3.8.3",
- "main": "./index.js"
+ "main": "./lib/typescript.js"
}
diff --git a/test/integration/typescript-version-warning/app/tsconfig.json b/test/integration/typescript-version-warning/app/tsconfig.json
index 93a83a407c40c..b8d597880a1ae 100644
--- a/test/integration/typescript-version-warning/app/tsconfig.json
+++ b/test/integration/typescript-version-warning/app/tsconfig.json
@@ -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"]
diff --git a/test/integration/typescript-version-warning/test/index.test.js b/test/integration/typescript-version-warning/test/index.test.js
index f47f1be582daf..a37ae2ad57a61 100644
--- a/test/integration/typescript-version-warning/test/index.test.js
+++ b/test/integration/typescript-version-warning/test/index.test.js
@@ -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 () => {
diff --git a/test/production/missing-dep-error/index.test.ts b/test/production/missing-dep-error/index.test.ts
deleted file mode 100644
index d888763af1ca2..0000000000000
--- a/test/production/missing-dep-error/index.test.ts
+++ /dev/null
@@ -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 hello world
- }
- `,
- },
- 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)
- })
-})