Recursive dependencies on the frontend

I want to talk about what problems recursive dependencies create at the front during development. And let's look at ways to solve dependencies and detect them.

About the problem

Recursive dependencies on the frontend can arise if modules refer to each other directly or indirectly.

When does it occur cyclic dependence (recursive dependency) on assembly, assembler (whether Webpack, Vite or any other instrument) won't build forever. Instead, it will try to resolve dependencies and, if it encounters a loop, it may fail the build process or defer some dependencies, resulting in modules not being fully loaded.

How does this manifest itself? Tests on jest may fail with an error that some variable is not defined. During the build of the project (but you shouldn’t rely on the builder, since it does not always interrupt the build) or on the user’s site, something will not load correctly or run.

About microfronts

Microfrontends may stop loading correctly.

In general, you need to be careful with microfrontends; if shared modules differ in versions, there may be a situation where something doesn’t work in our application. Therefore, you need to monitor the current versions of microfronts (I solved this problem using nx.dev, this is a tool with its dependency graphs and CI/CD configured so that the corresponding microfronts are updated if these changes affect them).

The problem of recursive dependencies of microfronts can be solved by changing the development approach itself. You can put the general code in the “library”, then the dependency between the microfronts may be removed.

In general, it is not advisable to create cyclic dependencies between microfronts. You can resolve the issue by using dynamic import of microfronts to load when needed. But that doesn't mean you won't have circular dependencies with them, it just makes it less likely.

And the authors of nx.dev themselves advise using dynamic imports when creating microfronts.

Direct recursive dependency

// A.ts
import { B } from './B';
export const A = () => B();

// B.ts
import { A } from './A';
export const B = () => A();

In this example, we have file A that imports file B, and file B imports A. This creates a recursion that never ends.

But if suddenly you do not use dependencies in both files, then the bundler (be it webpack or vite) simply will not include this dependency in the final assembly. And there will be no problem with addiction. This is just a warning that there is potentially a dependency, but it will arise when you call what you import. In more complex language, this is tree-shaking.

Indirect recursive dependency

// A.ts
import { B } from './B';
export const A = () => B();

// B.ts
import { C } from './C';
export const B = () => C();

// C.ts
import { D } from './D';
export const C = () => D();

// D.ts
import { A } from './A';
export const D = () => A();
What does the dependency look like schematically?

What does the dependency look like schematically?

Solutions

  1. Dynamic import loads modules on requestand if the recursion is not called before this point, problems can be avoided during the loading phase.

// A.ts
export const A = () => {
  import('./B').then(({ B }) => B());
};

// B.ts
export const B = () => {
  import('./A').then(({ A }) => A());
};

Here modules A and B will not be loaded at the same time. They will be initialized only when execution reaches them.

  1. Move common code into a modulewhich will be used between these two files, but will not link to them.

// common.ts
export const valueFromCommon = "Value from common module";

export const commonFunction = () => {
  console.log("This is a common function.");
};

// A.ts
import { commonFunction, valueFromCommon } from './common';

export const A = () => {
  console.log("Calling common function from A");
  commonFunction(); // Вызов функции из общего модуля
  return valueFromCommon; // Используем значение из общего модуля
};

// B.ts
import { commonFunction, valueFromCommon } from './common';

export const B = () => {
  console.log("Calling common function from B");
  commonFunction(); // Вызов функции из общего модуля
  return valueFromCommon; // Используем значение из общего модуля
};
  1. Reorganization of the project structure

    A common error encountered in a project:

// components/index.ts
export * from './button'
export * from './style'

// components/button/index.ts:
import { styleColors } from '@/components'
const redColor = styleColors.red

// components/style/index.ts
export const styleColors = {
   red: '#fee';
}
What does it look like

What does it look like

In this example, the following happens:

  • components/index.ts exports everything from button And style.

  • components/button/index.ts imports styleColors from components/index.tswhich means it actually refers to styleColors from style/index.ts.

  • components/style/index.ts exports styleColorsbut at the moment when components/button/index.ts trying to access styleColorsIf index.ts has not yet completed its initialization, this may result in styleColors it turns out undefined.

Solution: in the file components/button/index.ts: use either immediate import of the required module import { styleColors } from '@/components/style', or a relative one (but it is better applicable at the level of one module).

Example relative imports in one module:

// components/index.ts
export * from './button'

// components/button/style.css
   ...

// components/button/Button.tsx
import style './style.css';

// components/button/index.ts
export * from './Button.tsx';
export * from './style.css'

In the Button.tsx file we import style.css relatively, thanks to this we do not have recursion. But if we did it like this:

// components/index.ts
export * from './button'

// components/button/style.css
   ...

// components/button/Button.tsx
import style '@/components';

// components/button/index.ts
export * from './Button.tsx';
export * from './style.css'

Then the Button.tsx file was with recursionbecause components/index.ts contains the button, and Button.tsx goes back to components/index.ts

Plus, in this example there is no need to export the style.css file externally, it is better to isolate the logic and remove this export from the button/index.ts file

Tools

  1. At one time I used eslint-plugin-import to detect recursive dependencies, but be careful because on large projects it can take a long time to run the check. You can play around with caching, and you can also use eslint_dinstead of the standard eslint for quickly running checks.

    When connected eslint-plugin-import It may happen that you will not find (even though you have recreated the recursive dependency) recursive dependencies in the project, this means that most likely your tsconfig configuration in eslint is configured incorrectly.

  2. Webpack with plugin circular-dependency-pluginbut it has problems parsing complex recursive dependencies. Therefore, it is better not to rely on it. It is also worth considering launching this plugin optionally, so as not to affect the main stage of the build. I usually run such checks when creating a merge request.

Conclusions

Resolving recursive dependencies is good for code stability and cleanliness. I tested the project assembly with and without a large number of recursive dependencies, but I didn’t notice any difference, because Collectors are good at working around these problems.

A little more about microfronts

By the way, in our project for assembling the SWC compiler, it was configured in such a way that it resolved recursive dependencies well and there were no problems in the client code. Then I migrated the project to babel and the application started breaking due to recursive dependencies. I had to specify in the package.json files of the microfrontends sideEffects

Property sideEffects V package.json with installation true indicates that your modules may have side effects. This behavior may affect circular dependency resolution in Webpack.

In the case of circular dependencies, when two modules (for example, A and B) refer to each other, the presence sideEffects: true can prevent problems associated with partial initialization. If one of the modules has side effects, Webpack will store it in the build, which can help avoid a situation where one of the modules is not fully initialized.

But use sideEffects: true doesn't mean you don't need to get rid of recursive dependencies, it's a way to avoid incomplete initialization while you fix recursive dependencies.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *