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();
Solutions
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.
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; // Используем значение из общего модуля
};
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';
}
In this example, the following happens:
components/index.ts exports everything from
button
Andstyle
.components/button/index.ts imports
styleColors
fromcomponents/index.ts
which means it actually refers tostyleColors
fromstyle/index.ts
.components/style/index.ts exports
styleColors
but at the moment whencomponents/button/index.ts
trying to accessstyleColors
Ifindex.ts
has not yet completed its initialization, this may result instyleColors
it turns outundefined
.
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
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.
Webpack with plugin
circular-dependency-plugin
but 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.