Ambiguous aliases in Vite monorepo
Asked Answered
S

2

6

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?

Shiller answered 9/2, 2023 at 19:49 Comment(2)
"packages/foo-bar/tsconfig.json and packages/foo-shared/tsconfig.json are similar:" Are they just similar (in which case, show exactly how they are different), or are they exactly the same?Klutz
@user Some unrelated options are different in my case, but can be same with the same result, with '@foo/shared' path being unused in @foo/shared projectShiller
K
3

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.

[...]

Klutz answered 15/2, 2023 at 22:24 Comment(5)
Thanks for the clarification. This doesn't solve the problem but sheds some light. This looks like a common problem, I was hoping there's a ready to use solution. Unfortunately, customResolver can bring more problems because it should mimic Vite default resolver config and add custom behavior to it. Currently one of possible options that I see is to fork/extend Rollup alias plugin (which is quite small) and patch resolveIdShiller
@EstusFlask So you got the same result after switching the order of the alias definitions? Do you mind also answering my question comment under the question post?Klutz
The order of aliases doesn't matter in this case because in this case there's only one '@' alias entry, it refers to packages/foo-bar/src/ inside @foo/shared when it's imported inside @foo/bar. It could be expected that duplicate entries may work like [{ find: '@', replace: '...foo-bar...'}, { find: '@', replace: '...foo-shared...'}], but they don't, when an alias fails to resolve, it doesn't falls back to another one, so only the first @ in the list is appliedShiller
@EstusFlask sorry if this comes off as annoying, but I can't tell from your response: did you actually try it? Or are you just assuming what will happen?Klutz
I tried this but it was evident that it's not supposed to work, '@foo/shared': path.join(__dirname, '../foo-shared/src'), '@': path.join(__dirname, './src') entries contain no information for a resolver to resolve @ to packages/foo-shared/src because __dirname is packages/foo-bar when @foo/bar project is built. My current workaround was to replace @ paths with @foo/shared inside foo-shared, it's workable but a PITA for maintenanceShiller
S
0

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()],
});

Stasny answered 29/9 at 13:12 Comment(1)
Thanks, but did you check it to be workable specifically in the scenario above (multiple tsconfigs with different path mappings)? The question already says that vite-tsconfig-paths worked the same way as aliasShiller

© 2022 - 2024 — McMap. All rights reserved.