Security News
Weekly Downloads Now Available in npm Package Search Results
Socket's package search now displays weekly downloads for npm packages, helping developers quickly assess popularity and make more informed decisions.
react-magnetic-di
Advanced tools
A new take for dependency injection / dependency replacement for your tests, storybooks and even experiments in production.
Dependency injection and component injection is not a new topic. Especially the ability to provide a custom implementation of a component/hook while testing or writing storybooks and examples it is extremely valuable. magnetic-di
takes inspiration from decorators, and with a touch of Babel magic allows you to optionally override imported/exported values in your code so you can swap implementations only when needed.
npm i react-magnetic-di
# or
yarn add react-magnetic-di
Edit your Babel config file (.babelrc
/ babel.config.js
/ ...) and add:
// ... other stuff like presets
plugins: [
// ... other plugins
'react-magnetic-di/babel-plugin',
],
This is where the magic happens: we safely rewrite the code to prepend di(...)
in every function scope, so that the dependency value can be swapped. We recommend to only add the plugin in development/test environments to avoid useless const assignment in production. You can either do that via multiple babel environment configs or by using enabledEnvs
option.
Once babel is configured, in your tests you can create type safe replacements via injectable
and then use runWithDi
, which will setup and clear the replacements for you after function execution is terminated. Such util also handles async code, but might require you to wrap the entire test to work effectively with scheduled code paths, or event driven implementations.
Assuming your source is:
import { fetchApi } from './fetch';
export async function myApiFetcher() {
const { data } = await fetchApi();
return data;
}
Then in the test you can write:
import { injectable, runWithDi } from 'react-magnetic-di';
import { fetchApi } from './fetch';
import { myApiFetcher } from '.';
it('should call the API', async () => {
// injectable() needs the original implementation as first argument
// and the replacement implementation as second
const fetchApiDi = injectable(fetchApi, jest.fn().mockResolvedValue('mock'));
const result = await runWithDi(() => myApiFetcher(), [fetchApiDi]);
expect(fetchApiDi).toHaveBeenCalled();
expect(result).toEqual('mock');
});
For React, we provide a specific DiProvider
to enable replacements across the entire tree. Given a component with complex UI interaction or data dependencies, like a Modal or an Apollo Query, we want to easily be able to integration test it:
import React from 'react';
import { DiProvider, injectable } from 'react-magnetic-di';
import { Modal } from 'material-ui';
import { useQuery } from 'react-apollo-hooks';
// injectable() needs the original implementation as first argument
// and the replacement implementation as second
const ModalOpenDi = injectable(Modal, () => <div />);
const useQueryDi = injectable(useQuery, () => ({ data: null }));
// test-testing-library.js
it('should render with react-testing-library', () => {
const { container } = render(<MyComponent />, {
wrapper: (p) => <DiProvider use={[ModalOpenDi, useQueryDi]} {...p} />,
});
expect(container).toMatchSnapshot();
});
// story.js
storiesOf('Modal content', module).add('with text', () => (
<DiProvider use={[ModalOpenDi, useQueryDi]}>
<MyComponent />
</DiProvider>
));
In the example above we replace all Modal
and useQuery
dependencies across all components in the tree with the custom versions.
If you want to replace dependencies only for a specific component (or set of components) you can use the target
prop:
// story.js
storiesOf('Modal content', module).add('with text', () => (
<DiProvider target={[MyComponent, MyOtherComponent]} use={[ModalOpenDi]}>
<DiProvider target={MyComponent} use={[useQueryDi]}>
<MyComponent />
<MyOtherComponent>
</DiProvider>
</DiProvider>
));
Here MyComponent
will have both ModalOpen
and useQuery
replaced while MyOtherComponent
only ModalOpen
. Be aware that target
needs an actual component declaration to work, so will not work in cases where the component is fully anonymous (eg: export default () => ...
or forwardRef(() => ...)
).
The library also provides a withDi
HOC in case you want to export components with dependencies already injected:
import React from 'react';
import { withDi, injectable } from 'react-magnetic-di';
import { Modal } from 'material-ui';
import { MyComponent } from './my-component';
const ModalOpenDi = injectable(Modal, () => <div />);
export default withDi(MyComponent, [ModalOpenDi]);
withDi
supports the same API and capabilities as DiProvider
, where target
is the third argument of the HOC withDi(MyComponent, [Modal], MyComponent)
in case you want to limit injection to a specific component only.
When you have the same dependency replaced multiple times, there are two behaviours that determine which injectable will "win":
DiProvider
wins. So you can declare more specific replacements by wrapping components with DiProvider
or withDi
and those will win over same type injectables on other top level DiProvider
suse
array wins. So you can define common injectables but still override each type case by case (eg: <DiProvider use={[...commonDeps, specificInjectable]}>
Currently the library does not enable automatic replacement of globals. To do that, you need to manually "tag" a global for replacement with di(myGlobal)
in the function scope. For instance:
import { di } from 'react-magnetic-di';
export async function myApiFetcher() {
// explicitly allow fetch global to be injected
di(fetch);
const { data } = await fetch();
return data;
}
Alternatively, you can create a "getter" so that the library will pick it up:
export const fetchApi = (...args) => fetch(...args);
export async function myApiFetcher() {
// now injection will automatically work
const { data } = await fetchApi();
return data;
}
Other times, there might be places in code where auto injection is problematic and might cause infinite loops. It might be the case if you are creating an injectable that then imports the replacement source itself.
For those scenarios, you can add a comment at the top of the function scope to tell the Babel plugin to skip that scope:
import { fetchApi } from './fetch';
export async function myApiFetcher() {
// di-ignore
const { data } = await fetchApi();
return data;
}
By default magnetic-di
does not complain if an injectable is not used or if a dependency has not being replaced. In large codebases however, that might led to issues with stale, unused injectables or with lack of knowledge in what could be replaced. To ease introspection, the library provides a stats
API that returns unused
injectables.
stats.unused()
returns an array of entries { get(), error() }
for all injectables that have not been used since stats.reset()
has been calledThis is an example of stats guard implementation using the returned error()
helper:
import { stats } from 'react-magnetic-di';
beforeEach(() => {
// it's important to reset the stats after each test
stats.reset();
});
afterEach(() => {
stats.unused().forEach((entry) => {
// throw an error pointing at the test with the unused injectable
throw entry.error();
});
});
The plugin provides a couple of options to explicitly disable auto injection for certain paths, and overall enable/disable replacements on specific environments:
// In your .babelrc / babel.config.js
// ... other stuff like presets
plugins: [
// ... other plugins
['react-magnetic-di/babel-plugin', {
// List of paths to ignore for auto injection. Recommended for mocks/tests
exclude: ['mocks', /test\.tsx?/],
// List of Babel or Node environment names where the plugin should be enabled
enabledEnvs: ['development', 'test'],
}],
],
When creating injectables you can provide a configuration object to customise some of its behaviour.
• displayName
: provide a custom name to make debugging easier:
const fetchApiDi = injectable(fetchApi, jest.fn(), { displayName: 'fetchApi' });
• target
: allows a replacement to only apply to specific function(s):
const fetchApiDi = injectable(fetchApi, jest.fn(), { target: fetchProjects });
• track
: skip reporting it in stats.unused()
(handy if you provide default injectables across tests):
const fetchApiDi = injectable(fetchApi, jest.fn(), { track: false });
• global
: allows a replacement to be available everywhere, at any point, until DiProvider
unmounts (alternatively use global
prop on DiProvider
to make all use
replacements act globally):
const fetchApiDi = injectable(fetchApi, jest.fn(), { global: true });
• use
: required prop, it is an array of replacements
• target
: allows a replacement to only apply to specific components(s)
• global
: boolean, allows replacements to be available outside the render phase
## ESLint plugin and rules
In order to enforce better practices, this package exports some ESLint rules:
| rule | description |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `order` | enforces `di(...)` to be the top of the block, to reduce chances of partial replacements |
| `no-duplicate` | prohibits marking the same dependency as injectable more than once in the same scope |
| `no-extraneous` | enforces dependencies to be consumed in the scope, to prevent unused variables |
| `no-restricted-injectable` | prohibits certain values from being injected: `paths: [{ name: string, importNames?: string[], message?: string }]` |
| `sort-dependencies` | require injectable dependencies to be sorted |
The rules are exported from `react-magnetic-di/eslint-plugin`. Unfortunately ESLint does not allow plugins that are not npm packages, so rules needs to be imported via other means for now.
## Current limitations
- `DiProvider` does not support dynamic `use` and `target` props (changes are ignored)
- Does not replace default props (or default parameters in general): so dependencies provided as default parameters (eg `function MyComponent ({ modal = Modal }) { ... }`) will be ignored. If you accept the dependency as prop/argument you should inject it via prop/argument, as having a double injection strategy is just confusing.
- Injecting primitive values (strings, booleans, numbers, ...) can be unreliable as we only have the actual value as reference, and so the library might not exactly know what to replace. In cases where multiple values might be replaced, a warning will be logged and we recommend you declare an inject a getter instead of the value itself.
- Targeting only works on named functions/classes, so it won't work on anonymous scopes (eg `export default () => { ... }` or `memo(() => { ... })`)
## FAQ
#### Cannot seem to make injectable work
A way to check if some dependency has been tagged for injection is to use the `debug` util, as it will print all values that are available for injection:
```js
import { debug } from 'react-magnetic-di';
// ...
console.log(debug(myApiFetcher));
// It will print ['fetchApi']
One possible reason for it to happen is that the context has been lost. Typical occurrences are async or deeply nested functions (especially in React).
The solution is setting the prop global
on DiProvider
(or the same injectable config) to better handle those scenarios (but refrain from abusing it).
To test your changes you can run the examples (with npm run start
).
Also, make sure you run npm run preversion
before creating you PR so you will double check that linting, types and tests are fine.
FAQs
Context driven dependency injection
The npm package react-magnetic-di receives a total of 8,170 weekly downloads. As such, react-magnetic-di popularity was classified as popular.
We found that react-magnetic-di demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 0 open source maintainers collaborating on the project.
Did you know?
Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.
Security News
Socket's package search now displays weekly downloads for npm packages, helping developers quickly assess popularity and make more informed decisions.
Security News
A Stanford study reveals 9.5% of engineers contribute almost nothing, costing tech $90B annually, with remote work fueling the rise of "ghost engineers."
Research
Security News
Socket’s threat research team has detected six malicious npm packages typosquatting popular libraries to insert SSH backdoors.