From ab93ef3fcda207a7fcac9fd276f2b9fc7360a33f Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 23 Mar 2020 12:04:41 +0100 Subject: [PATCH 1/9] Add support for tsconfig/json `paths` option --- packages/next/build/webpack-config.ts | 7 + .../webpack/plugins/jsconfig-paths-plugin.ts | 309 ++++++++++++++++++ 2 files changed, 316 insertions(+) create mode 100644 packages/next/build/webpack/plugins/jsconfig-paths-plugin.ts diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index d3b2d2551b896ab..4fb95a812608164 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -43,6 +43,7 @@ import { ProfilingPlugin } from './webpack/plugins/profiling-plugin' import { ReactLoadablePlugin } from './webpack/plugins/react-loadable-plugin' import { ServerlessPlugin } from './webpack/plugins/serverless-plugin' import { TerserPlugin } from './webpack/plugins/terser-webpack-plugin/src/index' +import { JsConfigPathsPlugin } from './webpack/plugins/jsconfig-paths-plugin' import WebpackConformancePlugin, { MinificationConformanceCheck, ReactSyncScriptsConformanceCheck, @@ -910,6 +911,12 @@ export default async function getBaseWebpackConfig( webpackConfig.resolve?.modules?.push(resolvedBaseUrl) } + if (jsConfig?.compilerOptions?.paths && resolvedBaseUrl) { + webpackConfig.resolve?.plugins?.push( + new JsConfigPathsPlugin(jsConfig, resolvedBaseUrl) + ) + } + webpackConfig = await buildConfiguration(webpackConfig, { rootDirectory: dir, customAppFile, diff --git a/packages/next/build/webpack/plugins/jsconfig-paths-plugin.ts b/packages/next/build/webpack/plugins/jsconfig-paths-plugin.ts new file mode 100644 index 000000000000000..5bf849bdbe901b8 --- /dev/null +++ b/packages/next/build/webpack/plugins/jsconfig-paths-plugin.ts @@ -0,0 +1,309 @@ +import { ResolvePlugin } from 'webpack' +import { join } from 'path' + +export interface Pattern { + prefix: string + suffix: string +} + +const asterisk = 0x2a + +export function hasZeroOrOneAsteriskCharacter(str: string): boolean { + let seenAsterisk = false + for (let i = 0; i < str.length; i++) { + if (str.charCodeAt(i) === asterisk) { + if (!seenAsterisk) { + seenAsterisk = true + } else { + // have already seen asterisk + return false + } + } + } + return true +} + +export function tryParsePattern(pattern: string): Pattern | undefined { + // This should be verified outside of here and a proper error thrown. + const indexOfStar = pattern.indexOf('*') + return indexOfStar === -1 + ? undefined + : { + prefix: pattern.substr(0, indexOfStar), + suffix: pattern.substr(indexOfStar + 1), + } +} + +export function startsWith(str: string, prefix: string): boolean { + return str.lastIndexOf(prefix, 0) === 0 +} + +export function endsWith(str: string, suffix: string): boolean { + const expectedPos = str.length - suffix.length + return expectedPos >= 0 && str.indexOf(suffix, expectedPos) === expectedPos +} + +function isPatternMatch({ prefix, suffix }: Pattern, candidate: string) { + return ( + candidate.length >= prefix.length + suffix.length && + startsWith(candidate, prefix) && + endsWith(candidate, suffix) + ) +} + +/** Return the object corresponding to the best pattern to match `candidate`. */ +export function findBestPatternMatch( + values: readonly T[], + getPattern: (value: T) => Pattern, + candidate: string +): T | undefined { + let matchedValue: T | undefined + // use length of prefix as betterness criteria + let longestMatchPrefixLength = -1 + + for (const v of values) { + const pattern = getPattern(v) + if ( + isPatternMatch(pattern, candidate) && + pattern.prefix.length > longestMatchPrefixLength + ) { + longestMatchPrefixLength = pattern.prefix.length + matchedValue = v + } + } + + return matchedValue +} + +/** + * patternStrings contains both pattern strings (containing "*") and regular strings. + * Return an exact match if possible, or a pattern match, or undefined. + * (These are verified by verifyCompilerOptions to have 0 or 1 "*" characters.) + */ +export function matchPatternOrExact( + patternStrings: readonly string[], + candidate: string +): string | Pattern | undefined { + const patterns: Pattern[] = [] + for (const patternString of patternStrings) { + if (!hasZeroOrOneAsteriskCharacter(patternString)) continue + const pattern = tryParsePattern(patternString) + if (pattern) { + patterns.push(pattern) + } else if (patternString === candidate) { + // pattern was matched as is - no need to search further + return patternString + } + } + + return findBestPatternMatch(patterns, _ => _, candidate) +} + +/** + * Tests whether a value is string + */ +export function isString(text: unknown): text is string { + return typeof text === 'string' +} + +/** + * Given that candidate matches pattern, returns the text matching the '*'. + * E.g.: matchedText(tryParsePattern("foo*baz"), "foobarbaz") === "bar" + */ +export function matchedText(pattern: Pattern, candidate: string): string { + return candidate.substring( + pattern.prefix.length, + candidate.length - pattern.suffix.length + ) +} + +export function patternText({ prefix, suffix }: Pattern): string { + return `${prefix}*${suffix}` +} + +/** + * Iterates through 'array' by index and performs the callback on each element of array until the callback + * returns a truthy value, then returns that value. + * If no such value is found, the callback is applied to each element of array and undefined is returned. + */ +export function forEach( + array: readonly T[] | undefined, + callback: (element: T, index: number) => U | undefined +): U | undefined { + if (array) { + for (let i = 0; i < array.length; i++) { + const result = callback(array[i], i) + if (result) { + return result + } + } + } + return undefined +} + +export const directorySeparator = '/' +const backslashRegExp = /\\/g + +/** + * Normalize path separators, converting `\` into `/`. + */ +export function normalizeSlashes(path: string): string { + return path.replace(backslashRegExp, directorySeparator) +} + +/** + * Formats a parsed path consisting of a root component (at index 0) and zero or more path + * segments (at indices > 0). + * + * ```ts + * getPathFromPathComponents(["/", "path", "to", "file.ext"]) === "/path/to/file.ext" + * ``` + */ +export function getPathFromPathComponents(pathComponents: readonly string[]) { + if (pathComponents.length === 0) return '' + + const root = + pathComponents[0] && ensureTrailingDirectorySeparator(pathComponents[0]) + return root + pathComponents.slice(1).join(directorySeparator) +} + +const slash = 0x2f +const backslash = 0x5c + +/** + * Determines whether a charCode corresponds to `/` or `\`. + */ +export function isAnyDirectorySeparator(charCode: number): boolean { + return charCode === slash || charCode === backslash +} + +/** + * Determines whether a path has a trailing separator (`/` or `\\`). + */ +export function hasTrailingDirectorySeparator(path: string) { + return ( + path.length > 0 && isAnyDirectorySeparator(path.charCodeAt(path.length - 1)) + ) +} + +export function ensureTrailingDirectorySeparator(path: string) { + if (!hasTrailingDirectorySeparator(path)) { + return path + directorySeparator + } + + return path +} + +export function some( + array: readonly T[] | undefined, + predicate?: (value: T) => boolean +): boolean { + if (array) { + if (predicate) { + for (const v of array) { + if (predicate(v)) { + return true + } + } + } else { + return array.length > 0 + } + } + return false +} + +/** + * Reduce an array of path components to a more simplified path by navigating any + * `"."` or `".."` entries in the path. + */ +export function reducePathComponents(components: readonly string[]) { + if (!some(components)) return [] + const reduced = [components[0]] + for (let i = 1; i < components.length; i++) { + const component = components[i] + if (!component) continue + if (component === '.') continue + if (component === '..') { + if (reduced.length > 1) { + if (reduced[reduced.length - 1] !== '..') { + reduced.pop() + continue + } + } else if (reduced[0]) continue + } + reduced.push(component) + } + return reduced +} + +const NODE_MODULES_REGEX = /node_modules/ +export class JsConfigPathsPlugin implements ResolvePlugin { + constructor(jsConfig, resolvedBaseUrl) { + this.paths = jsConfig.compilerOptions.paths + + this.resolvedBaseUrl = resolvedBaseUrl + } + apply(resolver: any) { + const paths = this.paths + const pathsKeys = Object.keys(paths) + const baseDirectory = this.resolvedBaseUrl + const target = resolver.ensureHook('resolve') + resolver + .getHook('described-resolve') + .tapPromise('JsConfigPathsPlugin', async (request, resolveContext) => { + // Exclude node_modules from paths support (speeds up resolving) + if (request.path.match(NODE_MODULES_REGEX)) { + return + } + + const moduleName = request.request + + // If the module name does not match any of the patterns in `paths` we hand off resolving to webpack + const matchedPattern = matchPatternOrExact(pathsKeys, moduleName) + if (!matchedPattern) { + return + } + + const matchedStar = isString(matchedPattern) + ? undefined + : matchedText(matchedPattern, moduleName) + const matchedPatternText = isString(matchedPattern) + ? matchedPattern + : patternText(matchedPattern) + + let triedPaths = [] + + for (const subst of paths[matchedPatternText]) { + const path = matchedStar ? subst.replace('*', matchedStar) : subst + const candidate = join(baseDirectory, path) + const [err, result] = await new Promise((resolve, reject) => { + const obj = Object.assign({}, request, { + request: candidate, + }) + resolver.doResolve( + target, + obj, + `Aliased with tsconfig.json or jsconfig.json ${matchedPatternText} to ${candidate}`, + resolveContext, + (err, result) => { + resolve([err, result]) + } + ) + }) + + // There's multiple paths values possible, so we first have to iterate them all first before throwing an error + if (err || result === undefined) { + triedPaths.push(candidate) + continue + } + + return result + } + + throw new Error(` + Request "${moduleName}" matched tsconfig.json or jsconfig.json "paths" pattern ${matchedPatternText} but could not be resolved. + Tried paths: ${triedPaths.join(' ')} + `) + }) + } +} From 6350d9412f02ae02a40b37dc38f400996e561940 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 23 Mar 2020 13:40:47 +0100 Subject: [PATCH 2/9] Add tests for paths in tsconfig.json --- .../typescript-baseurl/components/hi.tsx | 5 +++ .../typescript-paths/components/world.tsx | 5 +++ .../integration/typescript-paths/lib/a/api.ts | 1 + .../integration/typescript-paths/lib/b/api.ts | 1 + .../typescript-paths/lib/b/b-only.ts | 1 + .../typescript-paths/next.config.js | 6 +++ .../typescript-paths/pages/basic-alias.tsx | 9 ++++ .../pages/resolve-fallback.tsx | 5 +++ .../typescript-paths/pages/resolve-order.tsx | 5 +++ .../typescript-paths/test/index.test.js | 41 +++++++++++++++++++ .../typescript-paths/tsconfig.json | 24 +++++++++++ 11 files changed, 103 insertions(+) create mode 100644 test/integration/typescript-baseurl/components/hi.tsx create mode 100644 test/integration/typescript-paths/components/world.tsx create mode 100644 test/integration/typescript-paths/lib/a/api.ts create mode 100644 test/integration/typescript-paths/lib/b/api.ts create mode 100644 test/integration/typescript-paths/lib/b/b-only.ts create mode 100644 test/integration/typescript-paths/next.config.js create mode 100644 test/integration/typescript-paths/pages/basic-alias.tsx create mode 100644 test/integration/typescript-paths/pages/resolve-fallback.tsx create mode 100644 test/integration/typescript-paths/pages/resolve-order.tsx create mode 100644 test/integration/typescript-paths/test/index.test.js create mode 100644 test/integration/typescript-paths/tsconfig.json diff --git a/test/integration/typescript-baseurl/components/hi.tsx b/test/integration/typescript-baseurl/components/hi.tsx new file mode 100644 index 000000000000000..3283eafde3e3637 --- /dev/null +++ b/test/integration/typescript-baseurl/components/hi.tsx @@ -0,0 +1,5 @@ +import React from 'react' + +export function Hi(): JSX.Element { + return <>Hi +} diff --git a/test/integration/typescript-paths/components/world.tsx b/test/integration/typescript-paths/components/world.tsx new file mode 100644 index 000000000000000..d7d1f66258c778e --- /dev/null +++ b/test/integration/typescript-paths/components/world.tsx @@ -0,0 +1,5 @@ +import React from 'react' + +export function World(): JSX.Element { + return <>World +} diff --git a/test/integration/typescript-paths/lib/a/api.ts b/test/integration/typescript-paths/lib/a/api.ts new file mode 100644 index 000000000000000..a27fc1b9498a401 --- /dev/null +++ b/test/integration/typescript-paths/lib/a/api.ts @@ -0,0 +1 @@ +export default () => 'Hello from a' diff --git a/test/integration/typescript-paths/lib/b/api.ts b/test/integration/typescript-paths/lib/b/api.ts new file mode 100644 index 000000000000000..24ee786bd1a95b2 --- /dev/null +++ b/test/integration/typescript-paths/lib/b/api.ts @@ -0,0 +1 @@ +export default () => 'Hello from b' diff --git a/test/integration/typescript-paths/lib/b/b-only.ts b/test/integration/typescript-paths/lib/b/b-only.ts new file mode 100644 index 000000000000000..0f1c1ce795f53df --- /dev/null +++ b/test/integration/typescript-paths/lib/b/b-only.ts @@ -0,0 +1 @@ +export default () => 'Hello from only b' diff --git a/test/integration/typescript-paths/next.config.js b/test/integration/typescript-paths/next.config.js new file mode 100644 index 000000000000000..cc17cf48c578fd5 --- /dev/null +++ b/test/integration/typescript-paths/next.config.js @@ -0,0 +1,6 @@ +module.exports = { + onDemandEntries: { + // Make sure entries are not getting disposed. + maxInactiveAge: 1000 * 60 * 60, + }, +} diff --git a/test/integration/typescript-paths/pages/basic-alias.tsx b/test/integration/typescript-paths/pages/basic-alias.tsx new file mode 100644 index 000000000000000..f9b23a18297029c --- /dev/null +++ b/test/integration/typescript-paths/pages/basic-alias.tsx @@ -0,0 +1,9 @@ +import React from 'react' +import { World } from '@c/world' +export default function HelloPage(): JSX.Element { + return ( +
+ +
+ ) +} diff --git a/test/integration/typescript-paths/pages/resolve-fallback.tsx b/test/integration/typescript-paths/pages/resolve-fallback.tsx new file mode 100644 index 000000000000000..8b1dc93cd6bf4db --- /dev/null +++ b/test/integration/typescript-paths/pages/resolve-fallback.tsx @@ -0,0 +1,5 @@ +import React from 'react' +import api from '@lib/b-only' +export default function ResolveOrder(): JSX.Element { + return
{api()}
+} diff --git a/test/integration/typescript-paths/pages/resolve-order.tsx b/test/integration/typescript-paths/pages/resolve-order.tsx new file mode 100644 index 000000000000000..dd83cd62e8a8a3a --- /dev/null +++ b/test/integration/typescript-paths/pages/resolve-order.tsx @@ -0,0 +1,5 @@ +import React from 'react' +import api from '@lib/api' +export default function ResolveOrder(): JSX.Element { + return
{api()}
+} diff --git a/test/integration/typescript-paths/test/index.test.js b/test/integration/typescript-paths/test/index.test.js new file mode 100644 index 000000000000000..308df6366bcb8ea --- /dev/null +++ b/test/integration/typescript-paths/test/index.test.js @@ -0,0 +1,41 @@ +/* eslint-env jest */ +/* global jasmine */ +import { join } from 'path' +import cheerio from 'cheerio' +import { renderViaHTTP, findPort, launchApp, killApp } from 'next-test-utils' + +jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000 * 60 * 2 + +const appDir = join(__dirname, '..') +let appPort +let app + +async function get$(path, query) { + const html = await renderViaHTTP(appPort, path, query) + return cheerio.load(html) +} + +describe('TypeScript Features', () => { + describe('default behavior', () => { + beforeAll(async () => { + appPort = await findPort() + app = await launchApp(appDir, appPort, {}) + }) + afterAll(() => killApp(app)) + + it('should alias components', async () => { + const $ = await get$('/basic-alias') + expect($('body').text()).toMatch(/World/) + }) + + it('should resolve the first item in the array first', async () => { + const $ = await get$('/resolve-order') + expect($('body').text()).toMatch(/Hello from a/) + }) + + it('should resolve the first item in the array first', async () => { + const $ = await get$('/resolve-fallback') + expect($('body').text()).toMatch(/Hello from only b/) + }) + }) +}) diff --git a/test/integration/typescript-paths/tsconfig.json b/test/integration/typescript-paths/tsconfig.json new file mode 100644 index 000000000000000..29c96623ecbaafb --- /dev/null +++ b/test/integration/typescript-paths/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@c/*": ["components/*"], + "@lib/*": ["lib/a/*", "lib/b/*"] + }, + "esModuleInterop": true, + "module": "esnext", + "jsx": "preserve", + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true + }, + "exclude": ["node_modules"], + "include": ["next-env.d.ts", "components", "pages"] +} From 327689d07b0eef2b9e91455cfc269053d2b5cb12 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 23 Mar 2020 13:42:28 +0100 Subject: [PATCH 3/9] Don't apply aliases when paths is empty --- .../next/build/webpack/plugins/jsconfig-paths-plugin.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/next/build/webpack/plugins/jsconfig-paths-plugin.ts b/packages/next/build/webpack/plugins/jsconfig-paths-plugin.ts index 5bf849bdbe901b8..9d74e7db0397410 100644 --- a/packages/next/build/webpack/plugins/jsconfig-paths-plugin.ts +++ b/packages/next/build/webpack/plugins/jsconfig-paths-plugin.ts @@ -246,6 +246,12 @@ export class JsConfigPathsPlugin implements ResolvePlugin { apply(resolver: any) { const paths = this.paths const pathsKeys = Object.keys(paths) + + // If no aliases are added bail out + if (pathsKeys.length === 0) { + return + } + const baseDirectory = this.resolvedBaseUrl const target = resolver.ensureHook('resolve') resolver From 4c72944ae7c4a33043236bc21944d909e487154b Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 23 Mar 2020 14:02:00 +0100 Subject: [PATCH 4/9] Clean up unused methods and link to TypeScript license --- .../webpack/plugins/jsconfig-paths-plugin.ts | 135 ++---------------- 1 file changed, 11 insertions(+), 124 deletions(-) diff --git a/packages/next/build/webpack/plugins/jsconfig-paths-plugin.ts b/packages/next/build/webpack/plugins/jsconfig-paths-plugin.ts index 9d74e7db0397410..6fc11094ac70799 100644 --- a/packages/next/build/webpack/plugins/jsconfig-paths-plugin.ts +++ b/packages/next/build/webpack/plugins/jsconfig-paths-plugin.ts @@ -1,3 +1,8 @@ +/** + * This webpack resolver is largely based on TypeScript's "paths" handling + * The TypeScript license can be found here: + * https://github.com/microsoft/TypeScript/blob/214df64e287804577afa1fea0184c18c40f7d1ca/LICENSE.txt + */ import { ResolvePlugin } from 'webpack' import { join } from 'path' @@ -34,20 +39,11 @@ export function tryParsePattern(pattern: string): Pattern | undefined { } } -export function startsWith(str: string, prefix: string): boolean { - return str.lastIndexOf(prefix, 0) === 0 -} - -export function endsWith(str: string, suffix: string): boolean { - const expectedPos = str.length - suffix.length - return expectedPos >= 0 && str.indexOf(suffix, expectedPos) === expectedPos -} - function isPatternMatch({ prefix, suffix }: Pattern, candidate: string) { return ( candidate.length >= prefix.length + suffix.length && - startsWith(candidate, prefix) && - endsWith(candidate, suffix) + candidate.startsWith(prefix) && + candidate.endsWith(suffix) ) } @@ -121,122 +117,13 @@ export function patternText({ prefix, suffix }: Pattern): string { return `${prefix}*${suffix}` } -/** - * Iterates through 'array' by index and performs the callback on each element of array until the callback - * returns a truthy value, then returns that value. - * If no such value is found, the callback is applied to each element of array and undefined is returned. - */ -export function forEach( - array: readonly T[] | undefined, - callback: (element: T, index: number) => U | undefined -): U | undefined { - if (array) { - for (let i = 0; i < array.length; i++) { - const result = callback(array[i], i) - if (result) { - return result - } - } - } - return undefined -} - -export const directorySeparator = '/' -const backslashRegExp = /\\/g - -/** - * Normalize path separators, converting `\` into `/`. - */ -export function normalizeSlashes(path: string): string { - return path.replace(backslashRegExp, directorySeparator) -} - -/** - * Formats a parsed path consisting of a root component (at index 0) and zero or more path - * segments (at indices > 0). - * - * ```ts - * getPathFromPathComponents(["/", "path", "to", "file.ext"]) === "/path/to/file.ext" - * ``` - */ -export function getPathFromPathComponents(pathComponents: readonly string[]) { - if (pathComponents.length === 0) return '' - - const root = - pathComponents[0] && ensureTrailingDirectorySeparator(pathComponents[0]) - return root + pathComponents.slice(1).join(directorySeparator) -} - -const slash = 0x2f -const backslash = 0x5c - -/** - * Determines whether a charCode corresponds to `/` or `\`. - */ -export function isAnyDirectorySeparator(charCode: number): boolean { - return charCode === slash || charCode === backslash -} - -/** - * Determines whether a path has a trailing separator (`/` or `\\`). - */ -export function hasTrailingDirectorySeparator(path: string) { - return ( - path.length > 0 && isAnyDirectorySeparator(path.charCodeAt(path.length - 1)) - ) -} - -export function ensureTrailingDirectorySeparator(path: string) { - if (!hasTrailingDirectorySeparator(path)) { - return path + directorySeparator - } - - return path -} - -export function some( - array: readonly T[] | undefined, - predicate?: (value: T) => boolean -): boolean { - if (array) { - if (predicate) { - for (const v of array) { - if (predicate(v)) { - return true - } - } - } else { - return array.length > 0 - } - } - return false -} +const NODE_MODULES_REGEX = /node_modules/ /** - * Reduce an array of path components to a more simplified path by navigating any - * `"."` or `".."` entries in the path. + * Handles tsconfig.json or jsconfig.js "paths" option for webpack + * Largely based on how the TypeScript compiler handles it: + * https://github.com/microsoft/TypeScript/blob/1a9c8197fffe3dace5f8dca6633d450a88cba66d/src/compiler/moduleNameResolver.ts#L1362 */ -export function reducePathComponents(components: readonly string[]) { - if (!some(components)) return [] - const reduced = [components[0]] - for (let i = 1; i < components.length; i++) { - const component = components[i] - if (!component) continue - if (component === '.') continue - if (component === '..') { - if (reduced.length > 1) { - if (reduced[reduced.length - 1] !== '..') { - reduced.pop() - continue - } - } else if (reduced[0]) continue - } - reduced.push(component) - } - return reduced -} - -const NODE_MODULES_REGEX = /node_modules/ export class JsConfigPathsPlugin implements ResolvePlugin { constructor(jsConfig, resolvedBaseUrl) { this.paths = jsConfig.compilerOptions.paths From 53af59776f339070f7c0995e3184dd869bbc63ce Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 23 Mar 2020 14:02:13 +0100 Subject: [PATCH 5/9] Add tests for jsconfig --- .../jsconfig-paths/components/world.js | 5 +++ test/integration/jsconfig-paths/jsconfig.json | 9 ++++ test/integration/jsconfig-paths/lib/a/api.js | 1 + test/integration/jsconfig-paths/lib/b/api.js | 1 + .../jsconfig-paths/lib/b/b-only.js | 1 + .../integration/jsconfig-paths/next.config.js | 6 +++ .../jsconfig-paths/pages/basic-alias.js | 9 ++++ .../jsconfig-paths/pages/resolve-fallback.js | 5 +++ .../jsconfig-paths/pages/resolve-order.js | 5 +++ .../jsconfig-paths/test/index.test.js | 41 +++++++++++++++++++ 10 files changed, 83 insertions(+) create mode 100644 test/integration/jsconfig-paths/components/world.js create mode 100644 test/integration/jsconfig-paths/jsconfig.json create mode 100644 test/integration/jsconfig-paths/lib/a/api.js create mode 100644 test/integration/jsconfig-paths/lib/b/api.js create mode 100644 test/integration/jsconfig-paths/lib/b/b-only.js create mode 100644 test/integration/jsconfig-paths/next.config.js create mode 100644 test/integration/jsconfig-paths/pages/basic-alias.js create mode 100644 test/integration/jsconfig-paths/pages/resolve-fallback.js create mode 100644 test/integration/jsconfig-paths/pages/resolve-order.js create mode 100644 test/integration/jsconfig-paths/test/index.test.js diff --git a/test/integration/jsconfig-paths/components/world.js b/test/integration/jsconfig-paths/components/world.js new file mode 100644 index 000000000000000..5a93b9838685c12 --- /dev/null +++ b/test/integration/jsconfig-paths/components/world.js @@ -0,0 +1,5 @@ +import React from 'react' + +export function World() { + return <>World +} diff --git a/test/integration/jsconfig-paths/jsconfig.json b/test/integration/jsconfig-paths/jsconfig.json new file mode 100644 index 000000000000000..3b6323e2e50f013 --- /dev/null +++ b/test/integration/jsconfig-paths/jsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@c/*": ["components/*"], + "@lib/*": ["lib/a/*", "lib/b/*"] + } + } +} diff --git a/test/integration/jsconfig-paths/lib/a/api.js b/test/integration/jsconfig-paths/lib/a/api.js new file mode 100644 index 000000000000000..a27fc1b9498a401 --- /dev/null +++ b/test/integration/jsconfig-paths/lib/a/api.js @@ -0,0 +1 @@ +export default () => 'Hello from a' diff --git a/test/integration/jsconfig-paths/lib/b/api.js b/test/integration/jsconfig-paths/lib/b/api.js new file mode 100644 index 000000000000000..24ee786bd1a95b2 --- /dev/null +++ b/test/integration/jsconfig-paths/lib/b/api.js @@ -0,0 +1 @@ +export default () => 'Hello from b' diff --git a/test/integration/jsconfig-paths/lib/b/b-only.js b/test/integration/jsconfig-paths/lib/b/b-only.js new file mode 100644 index 000000000000000..0f1c1ce795f53df --- /dev/null +++ b/test/integration/jsconfig-paths/lib/b/b-only.js @@ -0,0 +1 @@ +export default () => 'Hello from only b' diff --git a/test/integration/jsconfig-paths/next.config.js b/test/integration/jsconfig-paths/next.config.js new file mode 100644 index 000000000000000..cc17cf48c578fd5 --- /dev/null +++ b/test/integration/jsconfig-paths/next.config.js @@ -0,0 +1,6 @@ +module.exports = { + onDemandEntries: { + // Make sure entries are not getting disposed. + maxInactiveAge: 1000 * 60 * 60, + }, +} diff --git a/test/integration/jsconfig-paths/pages/basic-alias.js b/test/integration/jsconfig-paths/pages/basic-alias.js new file mode 100644 index 000000000000000..d2a31a937ae1142 --- /dev/null +++ b/test/integration/jsconfig-paths/pages/basic-alias.js @@ -0,0 +1,9 @@ +import React from 'react' +import { World } from '@c/world' +export default function HelloPage() { + return ( +
+ +
+ ) +} diff --git a/test/integration/jsconfig-paths/pages/resolve-fallback.js b/test/integration/jsconfig-paths/pages/resolve-fallback.js new file mode 100644 index 000000000000000..148b4404d1fb592 --- /dev/null +++ b/test/integration/jsconfig-paths/pages/resolve-fallback.js @@ -0,0 +1,5 @@ +import React from 'react' +import api from '@lib/b-only' +export default function ResolveOrder() { + return
{api()}
+} diff --git a/test/integration/jsconfig-paths/pages/resolve-order.js b/test/integration/jsconfig-paths/pages/resolve-order.js new file mode 100644 index 000000000000000..f7fbe6cee827882 --- /dev/null +++ b/test/integration/jsconfig-paths/pages/resolve-order.js @@ -0,0 +1,5 @@ +import React from 'react' +import api from '@lib/api' +export default function ResolveOrder() { + return
{api()}
+} diff --git a/test/integration/jsconfig-paths/test/index.test.js b/test/integration/jsconfig-paths/test/index.test.js new file mode 100644 index 000000000000000..308df6366bcb8ea --- /dev/null +++ b/test/integration/jsconfig-paths/test/index.test.js @@ -0,0 +1,41 @@ +/* eslint-env jest */ +/* global jasmine */ +import { join } from 'path' +import cheerio from 'cheerio' +import { renderViaHTTP, findPort, launchApp, killApp } from 'next-test-utils' + +jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000 * 60 * 2 + +const appDir = join(__dirname, '..') +let appPort +let app + +async function get$(path, query) { + const html = await renderViaHTTP(appPort, path, query) + return cheerio.load(html) +} + +describe('TypeScript Features', () => { + describe('default behavior', () => { + beforeAll(async () => { + appPort = await findPort() + app = await launchApp(appDir, appPort, {}) + }) + afterAll(() => killApp(app)) + + it('should alias components', async () => { + const $ = await get$('/basic-alias') + expect($('body').text()).toMatch(/World/) + }) + + it('should resolve the first item in the array first', async () => { + const $ = await get$('/resolve-order') + expect($('body').text()).toMatch(/Hello from a/) + }) + + it('should resolve the first item in the array first', async () => { + const $ = await get$('/resolve-fallback') + expect($('body').text()).toMatch(/Hello from only b/) + }) + }) +}) From cba6d4829a7137acef22092a3fc0bc6e011ac03d Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 23 Mar 2020 14:13:15 +0100 Subject: [PATCH 6/9] Put feature under an experimental flag --- packages/next/build/webpack-config.ts | 8 ++++++-- .../next/build/webpack/plugins/jsconfig-paths-plugin.ts | 8 ++++++-- packages/next/next-server/server/config.ts | 1 + 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index 4fb95a812608164..1eb1f4548efcdcf 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -911,9 +911,13 @@ export default async function getBaseWebpackConfig( webpackConfig.resolve?.modules?.push(resolvedBaseUrl) } - if (jsConfig?.compilerOptions?.paths && resolvedBaseUrl) { + if ( + config.experimental.jsconfigPaths && + jsConfig?.compilerOptions?.paths && + resolvedBaseUrl + ) { webpackConfig.resolve?.plugins?.push( - new JsConfigPathsPlugin(jsConfig, resolvedBaseUrl) + new JsConfigPathsPlugin(jsConfig.compilerOptions.paths, resolvedBaseUrl) ) } diff --git a/packages/next/build/webpack/plugins/jsconfig-paths-plugin.ts b/packages/next/build/webpack/plugins/jsconfig-paths-plugin.ts index 6fc11094ac70799..e43e4f9a1cbf538 100644 --- a/packages/next/build/webpack/plugins/jsconfig-paths-plugin.ts +++ b/packages/next/build/webpack/plugins/jsconfig-paths-plugin.ts @@ -119,14 +119,18 @@ export function patternText({ prefix, suffix }: Pattern): string { const NODE_MODULES_REGEX = /node_modules/ +type Paths = { [match: string]: string[] } + /** * Handles tsconfig.json or jsconfig.js "paths" option for webpack * Largely based on how the TypeScript compiler handles it: * https://github.com/microsoft/TypeScript/blob/1a9c8197fffe3dace5f8dca6633d450a88cba66d/src/compiler/moduleNameResolver.ts#L1362 */ export class JsConfigPathsPlugin implements ResolvePlugin { - constructor(jsConfig, resolvedBaseUrl) { - this.paths = jsConfig.compilerOptions.paths + paths: Paths + resolvedBaseUrl: string + constructor(paths: Paths, resolvedBaseUrl: string) { + this.paths = paths this.resolvedBaseUrl = resolvedBaseUrl } diff --git a/packages/next/next-server/server/config.ts b/packages/next/next-server/server/config.ts index 2535ec0ac8e970e..69e73235c93ba28 100644 --- a/packages/next/next-server/server/config.ts +++ b/packages/next/next-server/server/config.ts @@ -41,6 +41,7 @@ const defaultConfig: { [key: string]: any } = { (Number(process.env.CIRCLE_NODE_TOTAL) || (os.cpus() || { length: 1 }).length) - 1 ), + jsconfigPaths: false, css: true, scss: true, documentMiddleware: false, From dede3281cc4df638023942369957b73271f74ad2 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 23 Mar 2020 14:14:44 +0100 Subject: [PATCH 7/9] Enable to see if tests pass --- packages/next/next-server/server/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/next/next-server/server/config.ts b/packages/next/next-server/server/config.ts index 69e73235c93ba28..555e01e1cbaafe3 100644 --- a/packages/next/next-server/server/config.ts +++ b/packages/next/next-server/server/config.ts @@ -41,7 +41,7 @@ const defaultConfig: { [key: string]: any } = { (Number(process.env.CIRCLE_NODE_TOTAL) || (os.cpus() || { length: 1 }).length) - 1 ), - jsconfigPaths: false, + jsconfigPaths: true, css: true, scss: true, documentMiddleware: false, From fd1b085cc798668ea48ba108191b36bd88f11ef1 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 23 Mar 2020 14:22:42 +0100 Subject: [PATCH 8/9] Update types --- .../webpack/plugins/jsconfig-paths-plugin.ts | 95 ++++++++++--------- 1 file changed, 49 insertions(+), 46 deletions(-) diff --git a/packages/next/build/webpack/plugins/jsconfig-paths-plugin.ts b/packages/next/build/webpack/plugins/jsconfig-paths-plugin.ts index e43e4f9a1cbf538..5e1380be0ac5fdd 100644 --- a/packages/next/build/webpack/plugins/jsconfig-paths-plugin.ts +++ b/packages/next/build/webpack/plugins/jsconfig-paths-plugin.ts @@ -147,60 +147,63 @@ export class JsConfigPathsPlugin implements ResolvePlugin { const target = resolver.ensureHook('resolve') resolver .getHook('described-resolve') - .tapPromise('JsConfigPathsPlugin', async (request, resolveContext) => { - // Exclude node_modules from paths support (speeds up resolving) - if (request.path.match(NODE_MODULES_REGEX)) { - return - } + .tapPromise( + 'JsConfigPathsPlugin', + async (request: any, resolveContext: any) => { + // Exclude node_modules from paths support (speeds up resolving) + if (request.path.match(NODE_MODULES_REGEX)) { + return + } - const moduleName = request.request + const moduleName = request.request - // If the module name does not match any of the patterns in `paths` we hand off resolving to webpack - const matchedPattern = matchPatternOrExact(pathsKeys, moduleName) - if (!matchedPattern) { - return - } + // If the module name does not match any of the patterns in `paths` we hand off resolving to webpack + const matchedPattern = matchPatternOrExact(pathsKeys, moduleName) + if (!matchedPattern) { + return + } - const matchedStar = isString(matchedPattern) - ? undefined - : matchedText(matchedPattern, moduleName) - const matchedPatternText = isString(matchedPattern) - ? matchedPattern - : patternText(matchedPattern) - - let triedPaths = [] - - for (const subst of paths[matchedPatternText]) { - const path = matchedStar ? subst.replace('*', matchedStar) : subst - const candidate = join(baseDirectory, path) - const [err, result] = await new Promise((resolve, reject) => { - const obj = Object.assign({}, request, { - request: candidate, + const matchedStar = isString(matchedPattern) + ? undefined + : matchedText(matchedPattern, moduleName) + const matchedPatternText = isString(matchedPattern) + ? matchedPattern + : patternText(matchedPattern) + + let triedPaths = [] + + for (const subst of paths[matchedPatternText]) { + const path = matchedStar ? subst.replace('*', matchedStar) : subst + const candidate = join(baseDirectory, path) + const [err, result] = await new Promise((resolve, reject) => { + const obj = Object.assign({}, request, { + request: candidate, + }) + resolver.doResolve( + target, + obj, + `Aliased with tsconfig.json or jsconfig.json ${matchedPatternText} to ${candidate}`, + resolveContext, + (err: any, result: any | undefined) => { + resolve([err, result]) + } + ) }) - resolver.doResolve( - target, - obj, - `Aliased with tsconfig.json or jsconfig.json ${matchedPatternText} to ${candidate}`, - resolveContext, - (err, result) => { - resolve([err, result]) - } - ) - }) - - // There's multiple paths values possible, so we first have to iterate them all first before throwing an error - if (err || result === undefined) { - triedPaths.push(candidate) - continue - } - return result - } + // There's multiple paths values possible, so we first have to iterate them all first before throwing an error + if (err || result === undefined) { + triedPaths.push(candidate) + continue + } + + return result + } - throw new Error(` + throw new Error(` Request "${moduleName}" matched tsconfig.json or jsconfig.json "paths" pattern ${matchedPatternText} but could not be resolved. Tried paths: ${triedPaths.join(' ')} `) - }) + } + ) } } From 0ed59ea88d0afcfa98e7725ec92f184d0cd15998 Mon Sep 17 00:00:00 2001 From: Tim Neutkens Date: Mon, 23 Mar 2020 14:55:27 +0100 Subject: [PATCH 9/9] Add feature under an experimental flag --- packages/next/next-server/server/config.ts | 2 +- test/integration/jsconfig-paths/next.config.js | 3 +++ test/integration/typescript-paths/next.config.js | 3 +++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/next/next-server/server/config.ts b/packages/next/next-server/server/config.ts index 555e01e1cbaafe3..69e73235c93ba28 100644 --- a/packages/next/next-server/server/config.ts +++ b/packages/next/next-server/server/config.ts @@ -41,7 +41,7 @@ const defaultConfig: { [key: string]: any } = { (Number(process.env.CIRCLE_NODE_TOTAL) || (os.cpus() || { length: 1 }).length) - 1 ), - jsconfigPaths: true, + jsconfigPaths: false, css: true, scss: true, documentMiddleware: false, diff --git a/test/integration/jsconfig-paths/next.config.js b/test/integration/jsconfig-paths/next.config.js index cc17cf48c578fd5..4c08f1e93f2488c 100644 --- a/test/integration/jsconfig-paths/next.config.js +++ b/test/integration/jsconfig-paths/next.config.js @@ -1,4 +1,7 @@ module.exports = { + experimental: { + jsconfigPaths: true, + }, onDemandEntries: { // Make sure entries are not getting disposed. maxInactiveAge: 1000 * 60 * 60, diff --git a/test/integration/typescript-paths/next.config.js b/test/integration/typescript-paths/next.config.js index cc17cf48c578fd5..4c08f1e93f2488c 100644 --- a/test/integration/typescript-paths/next.config.js +++ b/test/integration/typescript-paths/next.config.js @@ -1,4 +1,7 @@ module.exports = { + experimental: { + jsconfigPaths: true, + }, onDemandEntries: { // Make sure entries are not getting disposed. maxInactiveAge: 1000 * 60 * 60,