diff --git a/packages/create-next-app/helpers/install.ts b/packages/create-next-app/helpers/install.ts index 8a36345297acf88..4cac7d9747f5d74 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 e07653513653891..5b1980ef297599d 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 aa775327fc94267..7dac1bab01bb1f8 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 646fb93e2eca691..581cec0af01ee34 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 287a07ffb7821ef..25b73740cdd4ce3 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 10c3bd67f92d37e..5b81a859d2acf37 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 f6d252a0b43ff36..a0108c58b1e4a4d 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 000000000000000..503690ab81e1da4 --- /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 49c21d99d07a05f..000000000000000 --- 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 9062e2e278ba75e..9f06333166a319d 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 5f538ced0a2e943..799928af22db902 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 000000000000000..296068bbb66d68e --- /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 000000000000000..f1208886efac471 --- /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 000000000000000..0359c00285d08d0 --- /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 000000000000000..36f88fd7b2e80a3 --- /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 000000000000000..fec1d03f066ee6f --- /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 000000000000000..86498777ff51145 --- /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 000000000000000..859719d413a9821 --- /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 000000000000000..a24b0a435a589c1 --- /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 000000000000000..296068bbb66d68e --- /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 000000000000000..f1208886efac471 --- /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 000000000000000..0359c00285d08d0 --- /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 000000000000000..fec1d03f066ee6f --- /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 000000000000000..86498777ff51145 --- /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 000000000000000..859719d413a9821 --- /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 000000000000000..3075aad9c6766f0 --- /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 000000000000000..ef679796d489de2 --- /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 000000000000000..4f5cca72d8eabb3 --- /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 740559576178f4f..000000000000000 --- 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 000000000000000..ca151aa41523844 --- /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 5331dd9817e332b..69c3d513633ec81 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 93a83a407c40c84..b8d597880a1ae63 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 f47f1be582daf4f..a37ae2ad57a61f7 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 d888763af1ca225..000000000000000 --- 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) - }) -})