refactor: more reliable import map generation, supporting turbopack and tsconfig basePath (#11618)

This simplifies and cleans up import map generation and adds support for turbopack, as well as the tsconfig `compilerOptions.basePath` property.

Previously, relative import paths looked like this:

```ts
import { TestComponent as ___ } from 'test/admin/components/TestComponent.js'
```

Paths like these will be resolved based on the `compilerOptions.baseUrl` path of your tsconfig.

This had 2 problems:

### baseUrl support

 If your tsconfig baseUrl was not `"."`, this did not work, as the import map generator does not respect it
 
 ### Turbopack support
 
If Turbopack was used, certain import paths were not able to be resolved.

For example, if your component is outside the `baseDir`, the generated path looked like this:

```ts
import { TestComponent as ___ } from '/../test/admin/components/TestComponent.js'
```

This works fine in webpack, but breaks in turbopack.

## Solution

This PR ensures all import paths are relative, making them more predictable and reliable.

The same component will now generate the following import path which works in Turbopack and if a different `compilerOptions.basePath` property is set:

```ts
import { TestComponent as ___ } from '../../../test/admin/components/TestComponent.js'
```

It also adds unit tests
This commit is contained in:
Alessio Gravili
2025-03-11 09:56:41 -06:00
committed by GitHub
parent c7bb694249
commit 243cdb1901
9 changed files with 404 additions and 132 deletions

View File

@@ -0,0 +1,214 @@
import type { PayloadComponent } from '../../index.js'
import { addPayloadComponentToImportMap } from './utilities/addPayloadComponentToImportMap.js'
import { getImportMapToBaseDirPath } from './utilities/getImportMapToBaseDirPath.js'
describe('addPayloadComponentToImportMap', () => {
let importMap: Record<string, string>
let imports: Record<
string,
{
path: string
specifier: string
}
>
beforeEach(() => {
importMap = {}
imports = {}
jest.restoreAllMocks()
})
function componentPathTest({
baseDir,
importMapFilePath,
payloadComponent,
expectedPath,
expectedSpecifier,
expectedImportMapToBaseDirPath,
}: {
baseDir: string
importMapFilePath: string
payloadComponent: PayloadComponent
expectedPath: string
expectedImportMapToBaseDirPath: string
expectedSpecifier: string
}) {
const importMapToBaseDirPath = getImportMapToBaseDirPath({
baseDir,
importMapPath: importMapFilePath,
})
expect(importMapToBaseDirPath).toBe(expectedImportMapToBaseDirPath)
const { path, specifier } =
addPayloadComponentToImportMap({
importMapToBaseDirPath,
importMap,
imports,
payloadComponent,
}) ?? {}
expect(path).toBe(expectedPath)
expect(specifier).toBe(expectedSpecifier)
}
it('relative path with import map partially in base dir', () => {
componentPathTest({
baseDir: '/myPackage/test/myTest',
importMapFilePath: '/myPackage/app/(payload)/importMap.js',
payloadComponent: './MyComponent.js#MyExport',
expectedImportMapToBaseDirPath: '../../test/myTest/',
expectedPath: '../../test/myTest/MyComponent.js',
expectedSpecifier: 'MyExport',
})
})
it('relative path with import map partially in base dir 2', () => {
componentPathTest({
baseDir: '/myPackage/test/myTest',
importMapFilePath: '/myPackage/test/prod/app/(payload)/importMap.js',
payloadComponent: {
path: './MyComponent.js#MyExport',
},
expectedImportMapToBaseDirPath: '../../../myTest/',
expectedPath: '../../../myTest/MyComponent.js',
expectedSpecifier: 'MyExport',
})
})
it('relative path with import map partially in base dir 3', () => {
componentPathTest({
baseDir: '/myPackage/test/myTest',
importMapFilePath: '/myPackage/test/prod/app/(payload)/importMap.js',
payloadComponent: {
path: '../otherTest/MyComponent.js',
exportName: 'MyExport',
},
expectedImportMapToBaseDirPath: '../../../myTest/',
expectedPath: '../../../otherTest/MyComponent.js',
expectedSpecifier: 'MyExport',
})
})
it('relative path with import map within base dir', () => {
componentPathTest({
baseDir: '/myPackage/test/myTest',
importMapFilePath: '/myPackage/test/myTest/prod/app/(payload)/importMap.js',
payloadComponent: './MyComponent.js#MyExport',
expectedImportMapToBaseDirPath: '../../../',
expectedPath: '../../../MyComponent.js',
expectedSpecifier: 'MyExport',
})
})
it('relative path with import map not in base dir', () => {
componentPathTest({
baseDir: '/test/myTest',
importMapFilePath: '/app/(payload)/importMap.js',
payloadComponent: './MyComponent.js#MyExport',
expectedImportMapToBaseDirPath: '../../test/myTest/',
expectedPath: '../../test/myTest/MyComponent.js',
expectedSpecifier: 'MyExport',
})
})
it('relative path with import map not in base dir 2', () => {
componentPathTest({
baseDir: '/test/myTest',
importMapFilePath: '/app/(payload)/importMap.js',
payloadComponent: '../myOtherTest/MyComponent.js#MyExport',
expectedImportMapToBaseDirPath: '../../test/myTest/',
expectedPath: '../../test/myOtherTest/MyComponent.js',
expectedSpecifier: 'MyExport',
})
})
it('relative path with import map not in base dir, baseDir ending with slash', () => {
componentPathTest({
baseDir: '/test/myTest/',
importMapFilePath: '/app/(payload)/importMap.js',
payloadComponent: './MyComponent.js#MyExport',
expectedImportMapToBaseDirPath: '../../test/myTest/',
expectedPath: '../../test/myTest/MyComponent.js',
expectedSpecifier: 'MyExport',
})
})
it('relative path with import map not in base dir, component starting with slash', () => {
componentPathTest({
baseDir: '/test/myTest',
importMapFilePath: '/app/(payload)/importMap.js',
payloadComponent: '/MyComponent.js#MyExport',
expectedImportMapToBaseDirPath: '../../test/myTest/',
expectedPath: '../../test/myTest/MyComponent.js',
expectedSpecifier: 'MyExport',
})
})
it('aliased path', () => {
componentPathTest({
baseDir: '/test/myTest',
importMapFilePath: '/app/(payload)/importMap.js',
payloadComponent: '@components/MyComponent.js#MyExport',
expectedImportMapToBaseDirPath: '../../test/myTest/',
expectedPath: '@components/MyComponent.js',
expectedSpecifier: 'MyExport',
})
})
it('aliased path in PayloadComponent object', () => {
componentPathTest({
baseDir: '/test/',
importMapFilePath: '/app/(payload)/importMap.js',
payloadComponent: {
path: '@components/MyComponent.js',
},
expectedImportMapToBaseDirPath: '../../test/',
expectedPath: '@components/MyComponent.js',
expectedSpecifier: 'default',
})
})
it('relative path import starting with slash, going up', () => {
componentPathTest({
baseDir: '/test/myTest',
importMapFilePath: '/test/myTest/app/importMap.js',
payloadComponent: '/../MyComponent.js#MyExport',
expectedImportMapToBaseDirPath: '../',
expectedPath: '../../MyComponent.js',
expectedSpecifier: 'MyExport',
})
})
it('relative path import starting with dot-slash, going up', () => {
componentPathTest({
baseDir: '/test/myTest',
importMapFilePath: '/test/myTest/app/importMap.js',
payloadComponent: './../MyComponent.js#MyExport',
expectedImportMapToBaseDirPath: '../',
expectedPath: '../../MyComponent.js',
expectedSpecifier: 'MyExport',
})
})
it('importMap and baseDir in same directory', () => {
componentPathTest({
baseDir: '/test/myTest',
importMapFilePath: '/test/myTest/importMap.js',
payloadComponent: './MyComponent.js#MyExport',
expectedImportMapToBaseDirPath: './',
expectedPath: './MyComponent.js',
expectedSpecifier: 'MyExport',
})
})
it('baseDir within importMap dir', () => {
componentPathTest({
baseDir: '/test/myTest/components',
importMapFilePath: '/test/myTest/importMap.js',
payloadComponent: './MyComponent.js#MyExport',
expectedImportMapToBaseDirPath: './components/',
expectedPath: './components/MyComponent.js',
expectedSpecifier: 'MyExport',
})
})
})

View File

@@ -1,12 +1,13 @@
import crypto from 'crypto' /* eslint-disable no-console */
import fs from 'fs' import fs from 'fs'
import process from 'node:process' import process from 'node:process'
import path from 'path'
import type { PayloadComponent, SanitizedConfig } from '../../config/types.js' import type { PayloadComponent, SanitizedConfig } from '../../config/types.js'
import { iterateConfig } from './iterateConfig.js' import { iterateConfig } from './iterateConfig.js'
import { parsePayloadComponent } from './parsePayloadComponent.js' import { addPayloadComponentToImportMap } from './utilities/addPayloadComponentToImportMap.js'
import { getImportMapToBaseDirPath } from './utilities/getImportMapToBaseDirPath.js'
import { resolveImportMapFilePath } from './utilities/resolveImportMapFilePath.js'
type ImportIdentifier = string type ImportIdentifier = string
type ImportSpecifier = string type ImportSpecifier = string
@@ -37,54 +38,6 @@ export type ImportMap = {
[path: UserImportPath]: any [path: UserImportPath]: any
} }
export function addPayloadComponentToImportMap({
baseDir,
importMap,
imports,
payloadComponent,
}: {
baseDir: string
importMap: InternalImportMap
imports: Imports
payloadComponent: PayloadComponent
}) {
if (!payloadComponent) {
return
}
const { exportName, path: componentPath } = parsePayloadComponent(payloadComponent)
if (importMap[componentPath + '#' + exportName]) {
return
}
const importIdentifier =
exportName + '_' + crypto.createHash('md5').update(componentPath).digest('hex')
// e.g. if baseDir is /test/fields and componentPath is /components/Field.tsx
// then path needs to be /test/fields/components/Field.tsx NOT /users/username/project/test/fields/components/Field.tsx
// so we need to append baseDir to componentPath
if (componentPath.startsWith('.') || componentPath.startsWith('/')) {
const normalizedBaseDir = baseDir.replace(/\\/g, '/')
const finalPath = normalizedBaseDir.startsWith('/../')
? `${normalizedBaseDir}${componentPath.slice(1)}`
: path.posix.join(normalizedBaseDir, componentPath.slice(1))
imports[importIdentifier] = {
path:
componentPath.startsWith('.') || componentPath.startsWith('/') ? finalPath : componentPath,
specifier: exportName,
}
} else {
imports[importIdentifier] = {
path: componentPath,
specifier: exportName,
}
}
importMap[componentPath + '#' + exportName] = importIdentifier
}
export type AddToImportMap = (payloadComponent: PayloadComponent | PayloadComponent[]) => void export type AddToImportMap = (payloadComponent: PayloadComponent | PayloadComponent[]) => void
export async function generateImportMap( export async function generateImportMap(
@@ -100,49 +53,21 @@ export async function generateImportMap(
const importMap: InternalImportMap = {} const importMap: InternalImportMap = {}
const imports: Imports = {} const imports: Imports = {}
// Determine the root directory of the project - usually the directory where the src or app folder is located
const rootDir = process.env.ROOT_DIR ?? process.cwd() const rootDir = process.env.ROOT_DIR ?? process.cwd()
// get componentsBaseDir. const baseDir = config.admin.importMap.baseDir ?? process.cwd()
// E.g.:
// config.admin.importMap.baseDir = /test/fields/
// rootDir: /
// componentsBaseDir = /test/fields/
// or const importMapFilePath = resolveImportMapFilePath({
adminRoute: config.routes.admin,
importMapFile: config?.admin?.importMap?.importMapFile,
rootDir,
})
// E.g.: const importMapToBaseDirPath = getImportMapToBaseDirPath({
// config.admin.importMap.baseDir = /test/fields/ baseDir,
// rootDir: /test importMapPath: importMapFilePath,
// componentsBaseDir = /fields/ })
// or
// config.admin.importMap.baseDir = /
// rootDir: /
// componentsBaseDir = /
// E.g.:
// config.admin.importMap.baseDir = /test/fields/
// rootDir: /test/fields/prod
// componentsBaseDir = ../
// Check if rootDir is a subdirectory of baseDir
const baseDir = config.admin.importMap.baseDir
const isSubdirectory = path.relative(baseDir, rootDir).startsWith('..')
let componentsBaseDir
if (isSubdirectory) {
// Get the relative path from rootDir to baseDir
componentsBaseDir = path.relative(rootDir, baseDir)
} else {
// If rootDir is not a subdirectory, just return baseDir relative to rootDir
componentsBaseDir = `/${path.relative(rootDir, baseDir)}`
}
// Ensure result has a trailing slash
if (!componentsBaseDir.endsWith('/')) {
componentsBaseDir += '/'
}
const addToImportMap: AddToImportMap = (payloadComponent) => { const addToImportMap: AddToImportMap = (payloadComponent) => {
if (!payloadComponent) { if (!payloadComponent) {
@@ -157,16 +82,16 @@ export async function generateImportMap(
if (Array.isArray(payloadComponent)) { if (Array.isArray(payloadComponent)) {
for (const component of payloadComponent) { for (const component of payloadComponent) {
addPayloadComponentToImportMap({ addPayloadComponentToImportMap({
baseDir: componentsBaseDir,
importMap, importMap,
importMapToBaseDirPath,
imports, imports,
payloadComponent: component, payloadComponent: component,
}) })
} }
} else { } else {
addPayloadComponentToImportMap({ addPayloadComponentToImportMap({
baseDir: componentsBaseDir,
importMap, importMap,
importMapToBaseDirPath,
imports, imports,
payloadComponent, payloadComponent,
}) })
@@ -183,56 +108,26 @@ export async function generateImportMap(
await writeImportMap({ await writeImportMap({
componentMap: importMap, componentMap: importMap,
config,
fileName: 'importMap.js',
force: options?.force, force: options?.force,
importMap: imports, importMap: imports,
importMapFilePath,
log: shouldLog, log: shouldLog,
rootDir,
}) })
} }
export async function writeImportMap({ export async function writeImportMap({
componentMap, componentMap,
config,
fileName,
force, force,
importMap, importMap,
importMapFilePath,
log, log,
rootDir,
}: { }: {
componentMap: InternalImportMap componentMap: InternalImportMap
config: SanitizedConfig
fileName: string
force?: boolean force?: boolean
importMap: Imports importMap: Imports
importMapFilePath: string
log?: boolean log?: boolean
rootDir: string
}) { }) {
let importMapFilePath: string | undefined = undefined
if (config?.admin?.importMap?.importMapFile?.length) {
if (!fs.existsSync(config.admin.importMap.importMapFile)) {
throw new Error(
`Could not find the import map file at ${config.admin.importMap.importMapFile}`,
)
}
importMapFilePath = config.admin.importMap.importMapFile
} else {
const appLocation = path.resolve(rootDir, `app/(payload)${config.routes.admin}/`)
const srcAppLocation = path.resolve(rootDir, `src/app/(payload)${config.routes.admin}/`)
if (fs.existsSync(appLocation)) {
importMapFilePath = path.resolve(appLocation, fileName)
} else if (fs.existsSync(srcAppLocation)) {
importMapFilePath = path.resolve(srcAppLocation, fileName)
} else {
throw new Error(
`Could not find Payload import map folder. Looked in ${appLocation} and ${srcAppLocation}`,
)
}
}
const imports: string[] = [] const imports: string[] = []
for (const [identifier, { path, specifier }] of Object.entries(importMap)) { for (const [identifier, { path, specifier }] of Object.entries(importMap)) {
imports.push(`import { ${specifier} as ${identifier} } from '${path}'`) imports.push(`import { ${specifier} as ${identifier} } from '${path}'`)

View File

@@ -0,0 +1,87 @@
import crypto from 'crypto'
import path from 'path'
import type { PayloadComponent } from '../../../config/types.js'
import type { Imports, InternalImportMap } from '../index.js'
import { parsePayloadComponent } from './parsePayloadComponent.js'
/**
* Normalizes the component path based on the import map's base directory path.
*/
function getAdjustedComponentPath(importMapToBaseDirPath: string, componentPath: string): string {
// Normalize input paths to use forward slashes
const normalizedBasePath = importMapToBaseDirPath.replace(/\\/g, '/')
const normalizedComponentPath = componentPath.replace(/\\/g, '/')
// Base path starts with './' - preserve the './' prefix
// => import map is in a subdirectory of the base directory, or in the same directory as the base directory
if (normalizedBasePath.startsWith('./')) {
// Remove './' from component path if it exists
const cleanComponentPath = normalizedComponentPath.startsWith('./')
? normalizedComponentPath.substring(2)
: normalizedComponentPath
// Join the paths to preserve the './' prefix
return `${normalizedBasePath}${cleanComponentPath}`
}
return path.posix.join(normalizedBasePath, normalizedComponentPath)
}
/**
* Adds a payload component to the import map.
*/
export function addPayloadComponentToImportMap({
importMap,
importMapToBaseDirPath,
imports,
payloadComponent,
}: {
importMap: InternalImportMap
importMapToBaseDirPath: string
imports: Imports
payloadComponent: PayloadComponent
}): {
path: string
specifier: string
} | null {
if (!payloadComponent) {
return null
}
const { exportName, path: componentPath } = parsePayloadComponent(payloadComponent)
if (importMap[componentPath + '#' + exportName]) {
return null
}
const importIdentifier =
exportName + '_' + crypto.createHash('md5').update(componentPath).digest('hex')
importMap[componentPath + '#' + exportName] = importIdentifier
const isRelativePath = componentPath.startsWith('.') || componentPath.startsWith('/')
if (isRelativePath) {
const adjustedComponentPath = getAdjustedComponentPath(importMapToBaseDirPath, componentPath)
imports[importIdentifier] = {
path: adjustedComponentPath,
specifier: exportName,
}
return {
path: adjustedComponentPath,
specifier: exportName,
}
} else {
// Tsconfig alias or package import, e.g. '@payloadcms/ui' or '@/components/MyComponent'
imports[importIdentifier] = {
path: componentPath,
specifier: exportName,
}
return {
path: componentPath,
specifier: exportName,
}
}
}

View File

@@ -1,6 +1,5 @@
import type { PayloadComponent } from '../../config/types.js' import type { PayloadComponent } from '../../../config/types.js'
import type { ImportMap } from './index.js' import type { ImportMap } from '../index.js'
import { parsePayloadComponent } from './parsePayloadComponent.js' import { parsePayloadComponent } from './parsePayloadComponent.js'
export const getFromImportMap = <TOutput>(args: { export const getFromImportMap = <TOutput>(args: {

View File

@@ -0,0 +1,39 @@
import path from 'path'
/**
* Returns the path that navigates from the import map file to the base directory.
* This can then be prepended to relative paths in the import map to get the full, absolute path.
*/
export function getImportMapToBaseDirPath({
baseDir,
importMapPath,
}: {
/**
* Absolute path to the base directory
*/
baseDir: string
/**
* Absolute path to the import map file
*/
importMapPath: string
}): string {
const importMapDir = path.dirname(importMapPath)
// 1. Direct relative path from `importMapDir` -> `baseDir`
let relativePath = path.relative(importMapDir, baseDir).replace(/\\/g, '/')
// 2. If they're the same directory, path.relative will be "", so use "./"
if (!relativePath) {
relativePath = './'
} // Add ./ prefix for subdirectories of the current directory
else if (!relativePath.startsWith('.') && !relativePath.startsWith('/')) {
relativePath = `./${relativePath}`
}
// 3. For consistency ensure a trailing slash
if (!relativePath.endsWith('/')) {
relativePath += '/'
}
return relativePath
}

View File

@@ -1,5 +1,5 @@
// @ts-strict-ignore // @ts-strict-ignore
import type { PayloadComponent } from '../../config/types.js' import type { PayloadComponent } from '../../../config/types.js'
export function parsePayloadComponent(PayloadComponent: PayloadComponent): { export function parsePayloadComponent(PayloadComponent: PayloadComponent): {
exportName: string exportName: string

View File

@@ -0,0 +1,38 @@
import fs from 'fs'
import path from 'path'
/**
* Returns the path to the import map file. If the import map file is not found, it throws an error.
*/
export function resolveImportMapFilePath({
adminRoute = '/admin',
importMapFile,
rootDir,
}: {
adminRoute?: string
importMapFile?: string
rootDir: string
}) {
let importMapFilePath: string | undefined = undefined
if (importMapFile?.length) {
if (!fs.existsSync(importMapFile)) {
throw new Error(`Could not find the import map file at ${importMapFile}`)
}
importMapFilePath = importMapFile
} else {
const appLocation = path.resolve(rootDir, `app/(payload)${adminRoute}/`)
const srcAppLocation = path.resolve(rootDir, `src/app/(payload)${adminRoute}/`)
if (fs.existsSync(appLocation)) {
importMapFilePath = path.resolve(appLocation, 'importMap.js')
} else if (fs.existsSync(srcAppLocation)) {
importMapFilePath = path.resolve(srcAppLocation, 'importMap.js')
} else {
throw new Error(
`Could not find Payload import map folder. Looked in ${appLocation} and ${srcAppLocation}`,
)
}
}
return importMapFilePath
}

View File

@@ -6,8 +6,8 @@ export {
parseCookies, parseCookies,
} from '../auth/cookies.js' } from '../auth/cookies.js'
export { getLoginOptions } from '../auth/getLoginOptions.js' export { getLoginOptions } from '../auth/getLoginOptions.js'
export { getFromImportMap } from '../bin/generateImportMap/getFromImportMap.js' export { getFromImportMap } from '../bin/generateImportMap/utilities/getFromImportMap.js'
export { parsePayloadComponent } from '../bin/generateImportMap/parsePayloadComponent.js' export { parsePayloadComponent } from '../bin/generateImportMap/utilities/parsePayloadComponent.js'
export { defaults as collectionDefaults } from '../collections/config/defaults.js' export { defaults as collectionDefaults } from '../collections/config/defaults.js'
export { serverProps } from '../config/types.js' export { serverProps } from '../config/types.js'

View File

@@ -22,7 +22,7 @@ import type {
} from '../../fields/config/types.js' } from '../../fields/config/types.js'
import type { Payload } from '../../types/index.js' import type { Payload } from '../../types/index.js'
import { getFromImportMap } from '../../bin/generateImportMap/getFromImportMap.js' import { getFromImportMap } from '../../bin/generateImportMap/utilities/getFromImportMap.js'
import { MissingEditorProp } from '../../errors/MissingEditorProp.js' import { MissingEditorProp } from '../../errors/MissingEditorProp.js'
import { fieldAffectsData } from '../../fields/config/types.js' import { fieldAffectsData } from '../../fields/config/types.js'
import { flattenTopLevelFields, type ImportMap } from '../../index.js' import { flattenTopLevelFields, type ImportMap } from '../../index.js'