Estus Flask
Estus Flask

Reputation: 222989

Ambiguous aliases in Vite monorepo

The problem occurs for Vite monorepo, @ aliases are respected by TypeScript because of separate tsconfig files (can be visible in IDE) but aren't distinguished among the workspaces by Vite on build.

The project uses Yarn 1.x with workspaces, TypeScript 4.9, Vite 3.2, Lerna 6.4 (shouldn't affect the problem at this point)

Project structure is common for a monorepo:

packages/
  foo-bar/
    src/
      index.ts
    package.json
    tsconfig.json
    vite.config.ts
    yarn.lock
  foo-baz/
    (same as above)
  foo-shared/
    src/
      qux.ts
      quux.ts
    package.json
    tsconfig.json
    yarn.lock
lerna.json
package.json
tsconfig.json
yarn.lock

When one package (foo-bar) imports a module from another (foo-shared):

packages/foo-bar/src/index.ts:

import qux from `@foo/shared/qux';

Another package resolves local aliased imports to wrong package on build, because Vite is unaware of tsconfig aliases:

packages/foo-shared/src/qux.ts:

import quux from `@/quux'; // resolves to packages/foo-bar/src/quux.ts and errors

The error is something like:

[vite:load-fallback] Could not load ...\packages\foo-bar\src/quux (imported by ../foo-shared/src/qux.ts): ENOENT: no such file or directory, open '...\packages\foo-bar\src\stores\quux' error during build:

foo-shared is currently a dummy package which isn't built standalone, only aliased and used on other packages.

packages/foo-bar/vite.config.ts:

  // ...
  export default defineConfig({
    resolve: {
      alias: {
        '@': path.join(__dirname, './src'),
        '@foo/shared': path.join(__dirname, '../foo-shared/src'),
      },
    },
    / * some irrelevant options */
  });

packages/foo-bar/tsconfig.json and packages/foo-shared/tsconfig.json are similar:

{
  "extends": "@vue/tsconfig/tsconfig.web.json",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"],
      "@foo/shared/*": ["../foo-shared/src/*"]
    },
    "typeRoots": [
      "./node_modules/@types",
      "../../node_modules/@types",
    ]
  },
  "include": [
     "src/**/*.ts",
     "src/**/*.d.ts",
     "src/**/*.vue"
   ],
  "exclude": [
    "node_modules"
  ],
}

I tried to replace resolve.alias with vite-tsconfig-paths plugin without success. It didn't affect the aliases at all out of the box, and I cannot be sure it's usable for this case.

How can Vite be configured to resolve paths that begin with "@" to different paths depending on the path of parent module?

Upvotes: 7

Views: 4276

Answers (2)

Wickramaranga
Wickramaranga

Reputation: 1181

Instead of using alias, try using the vite-tsconfig-paths plugin.

e.g.,

import react from '@vitejs/plugin-react-swc';
import tsconfigPaths from 'vite-tsconfig-paths';
import { defineConfig } from 'vite';

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [tsconfigPaths(), react()],
});

Upvotes: 0

starball
starball

Reputation: 51943

From the Vite docs on the the resolve.alias option:

Will be passed to @rollup/plugin-alias as its entries option. Can either be an object, or an array of { find, replacement, customResolver } pairs.

Unfortunately, at the time of this writing, the readme for rollup's resolve-alias plugin is... sparse:

Type: Function | Object
Default: null

Instructs the plugin to use an alternative resolving algorithm, rather than the Rollup's resolver. Please refer to the Rollup documentation for more information about the resolveId hook. For a detailed example, see: Custom Resolvers.

And the "detailed example" of a customResolver being referred to is not instructive at all if you actually want to know how to write one instead of using another mostly-pre-built one (one is left wondering what a resolveId hook is, and how it is relevant. For reference, I'm looking at the docs for v4.0.3. Hopefully they'll be better in the future)

Its type declaration file helps fill in the blanks. You can find it here: https://github.com/rollup/plugins/blob/master/packages/alias/types/index.d.ts, where you'll see something like:

import type { Plugin, PluginHooks } from 'rollup';

type MapToFunction<T> = T extends Function ? T : never;

export type ResolverFunction = MapToFunction<PluginHooks['resolveId']>;

export interface ResolverObject {
  buildStart?: PluginHooks['buildStart'];
  resolveId: ResolverFunction;
}

export interface Alias {
  find: string | RegExp;
  replacement: string;
  customResolver?: ResolverFunction | ResolverObject | null;
}

export interface RollupAliasOptions {
  /** blah blah not relevant for vite.js */
  customResolver?: /* blah blah not relevant for vite.js */;

  /**
   * Specifies an `Object`, or an `Array` of `Object`,
   * which defines aliases used to replace values in `import` or `require` statements.
   * With either format, the order of the entries is important,
   * in that the first defined rules are applied first.
   */
  entries?: readonly Alias[] | { [find: string]: string };
}

In particular, that last part of the doc comment for RollupAliasOptions#entries is important. I'll wager you can resolve your issue by reordering your resolve.alias entries in your vite.config.js:

alias: {
  '@foo/shared': path.join(__dirname, '../foo-shared/src'), // moved to be first
  '@': path.join(__dirname, './src'),
}

Now, if that doesn't work, or you find yourself in the future wanting to do anything where that doesn't suffice, you can write a custom resolver (see how the Alias type has a customResolver field?). This should answer your ending question: "How can Vite be configured to resolve paths that begin with "@" to different paths depending on the path of parent module?"

For that, you can see the linked docs in the rollup/plugin-alias docs: https://rollupjs.org/plugin-development/#resolveid. Here's a bit of relevant excerpt from the docs (in particular, note the importer parameter):

Type: ResolveIdHook
Kind: async, first
Previous: buildStart if we are resolving an entry point, moduleParsed if we are resolving an import, or as fallback for resolveDynamicImport. Additionally, this hook can be triggered during the build phase from plugin hooks by calling this.emitFile to emit an entry point or at any time by calling this.resolve to manually resolve an id
Next: load if the resolved id has not yet been loaded, otherwise buildEnd
type ResolveIdHook = (
  source: string,
  importer: string | undefined,
  options: {
      assertions: Record<string, string>;
      custom?: { [plugin: string]: any };
      isEntry: boolean;
  }
) => ResolveIdResult;

type ResolveIdResult = string | null | false | PartialResolvedId;

interface PartialResolvedId {
  id: string;
  external?: boolean | 'absolute' | 'relative';
  assertions?: Record<string, string> | null;
  meta?: { [plugin: string]: any } | null;
  moduleSideEffects?: boolean | 'no-treeshake' | null;
  resolvedBy?: string | null;
  syntheticNamedExports?: boolean | string | null;
}

Defines a custom resolver. A resolver can be useful for e.g. locating third-party dependencies. Here source is the importee exactly as it is written in the import statement, i.e. for

import { foo } from '../bar.js';

the source will be "../bar.js".

The importer is the fully resolved id of the importing module. When resolving entry points, importer will usually be undefined. An exception here are entry points generated via this.emitFile as here, you can provide an importer argument.

[...]

Returning null defers to other resolveId functions and eventually the default resolution behavior. Returning false signals that source should be treated as an external module and not included in the bundle. If this happens for a relative import, the id will be renormalized the same way as when the external option is used.

[...]

Upvotes: 3

Related Questions