Skip to content

Commit

Permalink
Use node module resolution (#6425)
Browse files Browse the repository at this point in the history
**Problem**
Utopia's module resolution logic is based on the [2019 node module
resolution](https://web.archive.org/web/20190213102857/https://nodejs.org/api/modules.html#modules_all_together),
but that logic has significantly evolved since then. This means that
there are plenty of cases where the module resolution whilst running a
project in Utopia would not match that whilst running elsewhere, causing
certain projects to break in unexpected ways.

**Fix:**
I've implemented the latest node.js module resolution logic from
https://nodejs.org/api/modules.html#all-together. Since this is of
course running in the browser, this also still needs to take into
account the [`browser` field of the
`package.json`](https://github.com/defunctzombie/package-browser-field-spec),
which was already implemented but I wanted to call it out as that is one
area where this differs from the node.js spec.

There is still a remaining `FIXME` in here about partial path matching
when checking the `imports` and `exports` fields. I'll tackle that in a
followup PR.

Fixes #6187
  • Loading branch information
Rheeseyb authored Oct 11, 2024
1 parent 6ec725c commit da6bf80
Show file tree
Hide file tree
Showing 11 changed files with 1,006 additions and 412 deletions.
90 changes: 77 additions & 13 deletions editor/src/components/custom-code/code-file.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,12 @@ import {
textFileContents,
unparsed,
} from '../../core/shared/project-file-types'
import { addFileToProjectContents, getTextFileByPath } from '../assets'
import {
addFileToProjectContents,
contentsToTree,
getTextFileByPath,
type ProjectContentTreeRoot,
} from '../assets'
import type { ExportsInfo, MultiFileBuildResult } from '../../core/workers/common/worker-types'
import { createBuiltInDependenciesList } from '../../core/es-modules/package-manager/built-in-dependencies-list'

Expand All @@ -53,6 +58,15 @@ function transpileCode(
const appJSCode =
"\nimport * as React from 'react'\nimport {\n Ellipse,\n HelperFunctions,\n Image,\n NodeImplementations,\n Rectangle,\n Text,\n View,\n} from 'utopia-api'\nimport {\n colorTheme,\n Button,\n Dialog,\n Icn,\n Icons,\n LargerIcons,\n FunctionIcons,\n MenuIcons,\n Isolator,\n TabComponent,\n Tooltip,\n ActionSheet,\n Avatar,\n ControlledTextArea,\n Title,\n H1,\n H2,\n H3,\n Subdued,\n InspectorSectionHeader,\n InspectorSubsectionHeader,\n FlexColumn,\n FlexRow,\n ResizableFlexColumn,\n PopupList,\n Section,\n SectionTitleRow,\n SectionBodyArea,\n UtopiaListSelect,\n UtopiaListItem,\n CheckboxInput,\n NumberInput,\n StringInput,\n OnClickOutsideHOC,\n} from 'uuiui'\n\nexport var canvasMetadata = {\n specialNodes: [],\n nodeMetadata: {},\n scenes: [\n {\n component: 'App',\n frame: { height: 812, left: 0, width: 375, top: 0 },\n props: { layout: { top: 0, left: 0, bottom: 0, right: 0 } },\n container: { layoutSystem: 'pinSystem' },\n },\n ],\n elementMetadata: {},\n}\n\nexport var App = (props) => {\n return (\n <View\n style={{ ...props.style, backgroundColor: colorTheme.white.value }}\n layout={{ layoutSystem: 'pinSystem' }}\n data-uid={'aaa'}\n ></View>\n )\n}\n\n"

const SampleSingleFileProjectContents: ProjectContentTreeRoot = contentsToTree({
'/app.js': textFile(
textFileContents(appJSCode, unparsed, RevisionsState.CodeAhead),
null,
null,
0,
),
})

const SampleSingleFileBuildResult = transpileCode(['/app.js'], {
'/app.js': appJSCode,
})
Expand Down Expand Up @@ -84,15 +98,40 @@ const SampleSingleFileExportsInfo = [
},
]

const multiFileAppJSCode =
"\nimport * as React from 'react'\nimport {\n Ellipse,\n HelperFunctions,\n Image,\n NodeImplementations,\n Rectangle,\n Text,\n View,\n} from 'utopia-api'\nimport {\n colorTheme,\n Button,\n Dialog,\n Icn,\n Icons,\n LargerIcons,\n FunctionIcons,\n MenuIcons,\n Isolator,\n TabComponent,\n Tooltip,\n ActionSheet,\n Avatar,\n ControlledTextArea,\n Title,\n H1,\n H2,\n H3,\n Subdued,\n InspectorSectionHeader,\n InspectorSubsectionHeader,\n FlexColumn,\n FlexRow,\n ResizableFlexColumn,\n PopupList,\n Section,\n SectionTitleRow,\n SectionBodyArea,\n UtopiaListSelect,\n UtopiaListItem,\n CheckboxInput,\n NumberInput,\n StringInput,\n OnClickOutsideHOC,\n} from 'uuiui'\n\nexport var canvasMetadata = {\n specialNodes: [],\n nodeMetadata: {},\n scenes: [\n {\n component: 'App',\n frame: { height: 812, left: 0, width: 375, top: 0 },\n props: { layout: { top: 0, left: 0, bottom: 0, right: 0 } },\n container: { layoutSystem: 'pinSystem' },\n },\n ],\n elementMetadata: {},\n}\n\n\nexport var App = (props) => {\n return (\n <View\n style={{ ...props.style, backgroundColor: colorTheme.white.value }}\n layout={{ layoutSystem: 'pinSystem' }}\n data-uid={'aaa'}\n ></View>\n )\n}\n\n"
const multiFileComponentsJSCode =
"// component library\nimport * as React from 'react'\nimport { Text, View } from 'utopia-api'\n\nexport default (props) => (\n <View layout={props.layout} style={props.style} onMouseDown={props.onMouseDown}>\n <Text\n style={{ fontSize: 16, textAlign: 'center' }}\n text={props.text}\n layout={{\n left: 0,\n top: 10,\n width: '100%',\n height: '100%',\n }}\n textSizing={'fixed'}\n />\n </View>\n)\n\nexport const LABEL = 'press me! 😉'\n\nexport const ComponentWithProps = (props) => {\n return (\n <div\n style={{\n ...props.style,\n backgroundColor: props.pink ? 'hotpink' : 'transparent',\n whiteSpace: 'normal',\n }}\n >\n {(props.text + ' ').repeat(props.num)}\n </div>\n )\n}\n\nComponentWithProps.propertyControls = {\n text: {\n type: 'string',\n title: 'Title',\n defaultValue: 'Change me',\n },\n num: {\n type: 'number',\n title: 'amount',\n defaultValue: 2,\n },\n pink: {\n type: 'boolean',\n title: 'Enabled',\n defaultValue: true,\n },\n}\n\n"
const multiFilePreviewJSXCode =
'import * as React from "react";\nimport * as ReactDOM from "react-dom";\nimport { App } from "../app";\n\nconst root = document.getElementById("root");\nif (root != null) {\nReactDOM.render(<App />, root);\n}'

const SampleMultiFileProjectContents: ProjectContentTreeRoot = contentsToTree({
'/app.js': textFile(
textFileContents(multiFileAppJSCode, unparsed, RevisionsState.CodeAhead),
null,
null,
0,
),
'/src/components.js': textFile(
textFileContents(multiFileComponentsJSCode, unparsed, RevisionsState.CodeAhead),
null,
null,
0,
),
'/public/preview.jsx': textFile(
textFileContents(multiFilePreviewJSXCode, unparsed, RevisionsState.CodeAhead),
null,
null,
0,
),
})

const SampleMultiFileBuildResult = transpileCode(
['/app.js', '/src/components.js', '/public/preview.jsx'],
{
'/app.js':
"\nimport * as React from 'react'\nimport {\n Ellipse,\n HelperFunctions,\n Image,\n NodeImplementations,\n Rectangle,\n Text,\n View,\n} from 'utopia-api'\nimport {\n colorTheme,\n Button,\n Dialog,\n Icn,\n Icons,\n LargerIcons,\n FunctionIcons,\n MenuIcons,\n Isolator,\n TabComponent,\n Tooltip,\n ActionSheet,\n Avatar,\n ControlledTextArea,\n Title,\n H1,\n H2,\n H3,\n Subdued,\n InspectorSectionHeader,\n InspectorSubsectionHeader,\n FlexColumn,\n FlexRow,\n ResizableFlexColumn,\n PopupList,\n Section,\n SectionTitleRow,\n SectionBodyArea,\n UtopiaListSelect,\n UtopiaListItem,\n CheckboxInput,\n NumberInput,\n StringInput,\n OnClickOutsideHOC,\n} from 'uuiui'\n\nexport var canvasMetadata = {\n specialNodes: [],\n nodeMetadata: {},\n scenes: [\n {\n component: 'App',\n frame: { height: 812, left: 0, width: 375, top: 0 },\n props: { layout: { top: 0, left: 0, bottom: 0, right: 0 } },\n container: { layoutSystem: 'pinSystem' },\n },\n ],\n elementMetadata: {},\n}\n\n\nexport var App = (props) => {\n return (\n <View\n style={{ ...props.style, backgroundColor: colorTheme.white.value }}\n layout={{ layoutSystem: 'pinSystem' }}\n data-uid={'aaa'}\n ></View>\n )\n}\n\n",
'/src/components.js':
"// component library\nimport * as React from 'react'\nimport { Text, View } from 'utopia-api'\n\nexport default (props) => (\n <View layout={props.layout} style={props.style} onMouseDown={props.onMouseDown}>\n <Text\n style={{ fontSize: 16, textAlign: 'center' }}\n text={props.text}\n layout={{\n left: 0,\n top: 10,\n width: '100%',\n height: '100%',\n }}\n textSizing={'fixed'}\n />\n </View>\n)\n\nexport const LABEL = 'press me! 😉'\n\nexport const ComponentWithProps = (props) => {\n return (\n <div\n style={{\n ...props.style,\n backgroundColor: props.pink ? 'hotpink' : 'transparent',\n whiteSpace: 'normal',\n }}\n >\n {(props.text + ' ').repeat(props.num)}\n </div>\n )\n}\n\nComponentWithProps.propertyControls = {\n text: {\n type: 'string',\n title: 'Title',\n defaultValue: 'Change me',\n },\n num: {\n type: 'number',\n title: 'amount',\n defaultValue: 2,\n },\n pink: {\n type: 'boolean',\n title: 'Enabled',\n defaultValue: true,\n },\n}\n\n",
'/public/preview.jsx':
'import * as React from "react";\nimport * as ReactDOM from "react-dom";\nimport { App } from "../app";\n\nconst root = document.getElementById("root");\nif (root != null) {\nReactDOM.render(<App />, root);\n}',
'/app.js': multiFileAppJSCode,
'/src/components.js': multiFileComponentsJSCode,
'/public/preview.jsx': multiFilePreviewJSXCode,
},
)

Expand Down Expand Up @@ -216,14 +255,25 @@ const SampleExportsInfoWithError = [
},
]

const exceptionThrowingCodeJSFile = "export const boom = (() => {\n throw Error('booom')\n})()\n"

const SampleExceptionThrowingProjectContents: ProjectContentTreeRoot = contentsToTree({
'/src/code.js': textFile(
textFileContents(exceptionThrowingCodeJSFile, unparsed, RevisionsState.CodeAhead),
null,
null,
0,
),
})

const SampleBuildResultWithException = transpileCode(['/src/code.js'], {
'/src/code.js': "export const boom = (() => {\n throw Error('booom')\n})()\n",
'/src/code.js': exceptionThrowingCodeJSFile,
})

const SampleExportsInfoWithException = [
{
filename: '/src/code.js',
code: "export const boom = (() => {\n throw Error('booom')\n})()\n",
code: exceptionThrowingCodeJSFile,
exportTypes: {
boom: {
type: 'never',
Expand Down Expand Up @@ -390,7 +440,9 @@ describe('Creating require function', () => {
createBuiltInDependenciesList(null),
)

expect(codeResultCache.curriedRequireFn({})('/', './app', false)).toMatchSnapshot()
expect(
codeResultCache.curriedRequireFn(SampleSingleFileProjectContents)('/', './app', false),
).toMatchSnapshot()
})
it('Creates require function for multi file build result', () => {
const codeResultCache = generateCodeResultCache(
Expand All @@ -403,8 +455,16 @@ describe('Creating require function', () => {
createBuiltInDependenciesList(null),
)

expect(codeResultCache.curriedRequireFn({})('/', './app', false)).toMatchSnapshot()
expect(codeResultCache.curriedRequireFn({})('/', './src/components', false)).toMatchSnapshot()
expect(
codeResultCache.curriedRequireFn(SampleMultiFileProjectContents)('/', './app', false),
).toMatchSnapshot()
expect(
codeResultCache.curriedRequireFn(SampleMultiFileProjectContents)(
'/',
'./src/components',
false,
),
).toMatchSnapshot()
})
it('Require throws exception for module code', () => {
const codeResultCache = generateCodeResultCache(
Expand All @@ -418,7 +478,11 @@ describe('Creating require function', () => {
)

expect(() =>
codeResultCache.curriedRequireFn({})('/', './src/code', false),
codeResultCache.curriedRequireFn(SampleExceptionThrowingProjectContents)(
'/',
'./src/code',
false,
),
).toThrowErrorMatchingSnapshot()
})
it('Require throws exception for import from non-existing module', () => {
Expand Down
3 changes: 3 additions & 0 deletions editor/src/components/custom-code/code-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,9 @@ export function generateCodeResultCache(
delete evaluationCache[fileToDelete]
}

// FIXME This actually isn't doing anything ever, because it appears that `updatedModules` above is always empty,
// however the require and resolve functions are still used, so we need to figure out which part of this code is
// still alive and which is dead
// MUTATION ALERT! This function is mutating editorState.nodeModules.files by inserting the project files into it.
incorporateBuildResult(nodeModules, projectContents, projectModules)

Expand Down
1 change: 1 addition & 0 deletions editor/src/core/es-modules/evaluator/evaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ function evaluateJs(

function firstErrorHandler(error: Error): void {
if (isEsModuleError(error)) {
// This is effectively implementing `MAYBE_DETECT_AND_LOAD` from https://nodejs.org/api/modules.html#all-together
const { transpiledCode, sourceMap } = transformToCommonJS(filePath, moduleCode)
evaluateWithHandler(transpiledCode, sourceMap, secondErrorHandler)
} else {
Expand Down
228 changes: 228 additions & 0 deletions editor/src/core/es-modules/package-manager/module-resolution-esm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import { getPartsFromPath, makePathFromParts, normalizePath } from '../../../utils/path-utils'
import type { ParseResult } from '../../../utils/value-parser-utils'
import { foldEither } from '../../shared/either'
import { isEsCodeFile } from '../../shared/project-file-types'
import type {
ExportsField,
FileForPathParts,
FileLookupFn,
FileLookupResult,
PartialPackageJsonDefinition,
} from './module-resolution-utils'
import {
findClosestPackageScopeToPath,
getPartialPackageJson,
isResolveSuccess,
resolveNotPresent,
} from './module-resolution-utils'

// Using the logic from https://nodejs.org/api/modules.html#all-together
// This is separated to emphasize that these functions act an ESM compatibility
// layer in CommonJS

// LOAD_PACKAGE_IMPORTS(X, DIR)
export function loadPackageImports(
path: string,
originPath: string,
packageJsonLookupFn: FileForPathParts,
lookupFn: FileLookupFn,
): FileLookupResult {
// Loading a module from the imports field mapping https://nodejs.org/api/packages.html#imports
// Find the closest package scope SCOPE to DIR.
const { packageJsonFileResult, packageJsonDir } = findClosestPackageScopeToPath(
originPath,
packageJsonLookupFn,
)

// If no scope was found, return
if (
!(isResolveSuccess(packageJsonFileResult) && isEsCodeFile(packageJsonFileResult.success.file))
) {
return resolveNotPresent
}

let possiblePackageJson: ParseResult<PartialPackageJsonDefinition>
try {
possiblePackageJson = getPartialPackageJson(packageJsonFileResult.success.file.fileContents)
} catch {
return resolveNotPresent
}

return foldEither(
(_) => resolveNotPresent,
(packageJson) => {
// If the SCOPE/package.json "imports" is null or undefined, return
const importsEntry = packageJson['imports']
if (importsEntry == null) {
return resolveNotPresent
}

return packageImportsExportsResolve(path, importsEntry, packageJsonDir, lookupFn)
},
possiblePackageJson,
)
}

// LOAD_PACKAGE_EXPORTS(X, DIR)
export function loadPackageExports(
path: string,
originPath: string,
packageJsonLookupFn: FileForPathParts,
lookupFn: FileLookupFn,
): FileLookupResult {
// Try to interpret X as a combination of NAME and SUBPATH where the name
// may have a @scope/ prefix and the subpath begins with a slash (`/`)
const regex = /((?:@[\w|\-|\.]+\/)*[\w|\-|\.]+)(\/[\w|\-|\.]*)*/
const matches = path.match(regex)
if (matches == null) {
// If X does not match this pattern, return
return resolveNotPresent
}

const [_combined, name, subpath] = matches

// If DIR/NAME/package.json is not a file, return
const pathToModule = normalizePath([...getPartsFromPath(originPath), name])
const packageJsonPathParts = pathToModule.concat('package.json')
const packageJsonFileResult = packageJsonLookupFn(packageJsonPathParts)
if (
!(isResolveSuccess(packageJsonFileResult) && isEsCodeFile(packageJsonFileResult.success.file))
) {
return resolveNotPresent
}

// Parse DIR/NAME/package.json, and look for "exports" field
let possiblePackageJson: ParseResult<PartialPackageJsonDefinition>
try {
possiblePackageJson = getPartialPackageJson(packageJsonFileResult.success.file.fileContents)
} catch {
return resolveNotPresent
}

return foldEither(
(_) => resolveNotPresent,
(packageJson) => {
// If "exports" is null or undefined, return.
const exportsEntry = packageJson['exports']
if (exportsEntry == null) {
return resolveNotPresent
}

return packageImportsExportsResolve(
`.${subpath}`,
exportsEntry,
makePathFromParts(pathToModule),
lookupFn,
)
},
possiblePackageJson,
)
}

// LOAD_PACKAGE_SELF(X, DIR)
export function loadPackageSelf(
path: string,
originPath: string,
packageJsonLookupFn: FileForPathParts,
lookupFn: FileLookupFn,
): FileLookupResult {
// Find the closest package scope SCOPE to DIR.
const { packageJsonFileResult, packageJsonDir } = findClosestPackageScopeToPath(
originPath,
packageJsonLookupFn,
)

// If no scope was found, return.
if (
!(isResolveSuccess(packageJsonFileResult) && isEsCodeFile(packageJsonFileResult.success.file))
) {
return resolveNotPresent
}

let possiblePackageJson: ParseResult<PartialPackageJsonDefinition>
try {
possiblePackageJson = getPartialPackageJson(packageJsonFileResult.success.file.fileContents)
} catch {
return resolveNotPresent
}
return foldEither(
(_) => resolveNotPresent,
(packageJson) => {
// If "exports" is null or undefined, return.
const exportsEntry = packageJson['exports']
if (exportsEntry == null) {
return resolveNotPresent
}

// If "name" is not the first segment of X, return
const nameEntry = packageJson.name
if (nameEntry == null || !path.startsWith(nameEntry)) {
return resolveNotPresent
}

return packageImportsExportsResolve(
`.${path.slice(nameEntry.length)}`,
exportsEntry,
packageJsonDir,
lookupFn,
)
},
possiblePackageJson,
)
}

// PACKAGE_IMPORTS_EXPORTS_RESOLVE
export function packageImportsExportsResolve(
path: string,
importsOrExportsEntry: ExportsField,
packageJsonDir: string,
lookupFn: FileLookupFn,
): FileLookupResult {
// FIXME partial path lookup because the exports field could be along the lines of `{ '@thing': { stuff: { ... }} }`
// in which case we would want to match that against the import `@thing/stuff`
const importsOrExportsResult =
typeof importsOrExportsEntry === 'string' ? importsOrExportsEntry : importsOrExportsEntry[path]
if (importsOrExportsResult == null) {
return resolveNotPresent
}

if (typeof importsOrExportsResult === 'string') {
// Lookup the import relative to the package.json we have found
return lookupFn(importsOrExportsResult, packageJsonDir)
}

// Otherwise importsOrExportsResult is an object, so now we need to check for the specific keys we're interested in
const defaultImportsOrExportsEntry = importsOrExportsResult['default']
const browserImportsOrExportsEntry = importsOrExportsResult['browser'] // The nodejs algorithm uses `node` here
const requireImportsOrExportsEntry = importsOrExportsResult['require']

if (defaultImportsOrExportsEntry != null) {
const fileToLookup =
typeof defaultImportsOrExportsEntry === 'string'
? defaultImportsOrExportsEntry
: defaultImportsOrExportsEntry['default']
if (fileToLookup != null) {
return lookupFn(fileToLookup, packageJsonDir)
}
}
if (browserImportsOrExportsEntry != null) {
const fileToLookup =
typeof browserImportsOrExportsEntry === 'string'
? browserImportsOrExportsEntry
: browserImportsOrExportsEntry['default']
if (fileToLookup != null) {
return lookupFn(fileToLookup, packageJsonDir)
}
}
if (requireImportsOrExportsEntry != null) {
const fileToLookup =
typeof requireImportsOrExportsEntry === 'string'
? requireImportsOrExportsEntry
: requireImportsOrExportsEntry['default']
if (fileToLookup != null) {
return lookupFn(fileToLookup, packageJsonDir)
}
}

return resolveNotPresent
}
Loading

0 comments on commit da6bf80

Please sign in to comment.