Skip to content

Commit c268966

Browse files
committed
Limit dependencies shared between workspaces
1 parent 72034fc commit c268966

File tree

1 file changed

+343
-0
lines changed

1 file changed

+343
-0
lines changed
Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
# Limit dependencies shared between workspaces
2+
3+
## Summary
4+
5+
For all workspaces within a project, the default behavior of dependency
6+
resolution will be as follows:
7+
8+
- Dependencies on other workspaces within the project will _always_ be
9+
resolved to the corresponding sibling workspace.
10+
- Peer dependencies of multiple workspaces within the project will always
11+
be shared with one another, raising an `ERESOLVE` if this is impossible.
12+
- All other dependencies will not be hoisted above the workspace location.
13+
However, if a satisfying version of a dependency is listed as a root
14+
project dependency, then that will be used.
15+
16+
Thus, all dependencies of workspace projects which are not
17+
`peerDependencies` or dependencies on sibling workspaces within the
18+
project, will never be hoisted above the workspace level _unless_ they are
19+
explicitly listed as a root project dependency.
20+
21+
The ability to load transitive dependencies without an explicit dependency
22+
on them is out of scope for this proposal, and covered by
23+
[`isolated-mode`](https://github.com/npm/rfcs/blob/main/accepted/0042-isolated-mode.md).
24+
25+
### Example
26+
27+
A project contains the following workspace definition:
28+
29+
```json
30+
{
31+
"workspace": ["packages/*"]
32+
}
33+
```
34+
35+
Workspaces exist at `packages/foo`, `packages/bar`, and `packages/baz`.
36+
37+
`packages/foo/package.json` contains:
38+
39+
```json
40+
{
41+
"name": "foo",
42+
"version": "1.0.0",
43+
"peerDependencies": {
44+
"react": "16"
45+
},
46+
"dependencies": {
47+
"bar": "1.x"
48+
},
49+
"devDependencies": {
50+
"webpack": "4",
51+
"webpack-cli": "4.9"
52+
}
53+
}
54+
```
55+
56+
`packages/bar/package.json` contains:
57+
58+
```json
59+
{
60+
"name": "bar",
61+
"version": "1.0.0",
62+
"peerDependencies": {
63+
"react": "16"
64+
},
65+
"dependencies": {
66+
"baz": "1.x"
67+
},
68+
"devDependencies": {
69+
"webpack": "5",
70+
"webpack-cli": "4.9"
71+
}
72+
}
73+
```
74+
75+
`packages/baz/package.json` contains:
76+
77+
```json
78+
{
79+
"name": "baz",
80+
"version": "2.0.0",
81+
"dependencies": {
82+
"foo": "1.x"
83+
}
84+
}
85+
```
86+
87+
In this example:
88+
89+
* The `bar` loaded by `foo` will be the one in `packages/bar`.
90+
* `webpack-cli` is duplicated in both `foo` and `bar` packages, instead of
91+
only `webpack` being duplicated.
92+
* The `baz` loaded by `bar` will be the one in `packages/bar`, _even though
93+
this is an invalid version_, because they are sibling workspaces within a
94+
project. (This will be flagged as an error in `npm ls`.)
95+
* The `react` loaded by both `foo` and `bar` will be the same instance.
96+
* If `baz` adds a peerDep on `react@17`, then this will cause an `ERESOLVE`
97+
error, because the peer dependency cannot be resolved. (This can be
98+
worked around by `baz` also listing `react` in `devDependencies`.)
99+
100+
## Motivation
101+
102+
A common use case for workspaces is to develop multiple related packages as
103+
a single overarching project, so that they can be more easily kept in sync
104+
and tested with one another.
105+
106+
The current implementation is somewhat naive. Workspaces are treated
107+
roughly the same as `file:` dependencies from the root project, with some
108+
additional semantics applied. Any shared dependencies are deduplicated up
109+
to the top-most `node_modules` folder if possible, as they would be if
110+
they were simple `file:` dependencies.
111+
112+
This RFC proposes more specific guidelines for which types of dependencies
113+
are shared in various scenarios.
114+
115+
There are use cases where the naive workspaces implementation, while
116+
simple, does not provide the ideal user experience.
117+
118+
Implementation details are out of scope for this RFC. This proposal
119+
focuses exclusively on the user experience and expectations for which
120+
packages are shared between workspaces in a project.
121+
122+
### Shadow Dependencies
123+
124+
A naive implementation of workspaces makes it easy to come to rely on
125+
undeclared dependencies. For example, consider a project with this
126+
`package.json` file:
127+
128+
```json
129+
{
130+
"workspaces": [
131+
"packages/*"
132+
]
133+
}
134+
```
135+
136+
and this folder structure:
137+
138+
```
139+
root
140+
+-- packages
141+
+-- a
142+
| +-- package.json
143+
| +-- index.js
144+
+-- b
145+
+-- package.json
146+
+-- index.js
147+
```
148+
149+
If `a` declares a dependency on `foo`, and `b` does _not_ declare that
150+
dependency, then the code in `b` can call `require('foo')` and it will work
151+
without any errors in development. But when `b` is published, and
152+
subsequently installed as a standalone project, it fails to find the `foo`
153+
dependency.
154+
155+
### Unexpectedly divergent Intra-Project Dependencies
156+
157+
A common goal of a workspaces project is to ensure that all workspaces in
158+
the project will work together when they depend on one another.
159+
160+
Consider a project containing many workspaces, including a shared library
161+
`foo` which many of the other workspaces depend on.
162+
163+
At some point, the developer bumps the version of `foo` from 1 to 2, with
164+
the intent of testing the new changes within all the other workspaces.
165+
However, as this is no longer a satisfying dependency for those other
166+
workspaces, the naive workspaces implementation will result in a package
167+
tree like this:
168+
169+
```
170+
root
171+
+-- node_modules
172+
| +-- foo (link: ../packages/foo)
173+
| +-- bar (link: ../packages/bar) (forgot to update dependency!)
174+
| +-- ... all other workspaces
175+
+-- packages
176+
+-- foo (v2)
177+
+-- bar
178+
| +-- node_modules
179+
| +-- foo (v1)
180+
+-- other workspaces
181+
+-- node_modules (empty, getting foo@2 from top level)
182+
```
183+
184+
If the user does not note the error, and publishes all of their packages
185+
together, they may end up releasing software that is incompatible with
186+
itself, and could have been detected during development.
187+
188+
### Unable to support divergent peer dependencies
189+
190+
It may be valuable in some cases to define multiple packages that each have
191+
a peer dependency on a _different_ version of some common module.
192+
193+
For example, imagine a plugin that is designed to work alongside React, and
194+
has adapters to implement its functionality in different versions of React.
195+
196+
```json
197+
{
198+
"name": "my-react-plugin",
199+
"version": "1.2.3",
200+
"description": "Works with React v15 through v17!",
201+
"optionalDependencies": {
202+
"my-react-plugin-15": "*",
203+
"my-react-plugin-16": "*",
204+
"my-react-plugin-17": "*"
205+
}
206+
}
207+
```
208+
209+
Then, each adapter has a `package.json` like this:
210+
211+
```json
212+
{
213+
"name": "my-react-plugin-15",
214+
"version": "1.2.3",
215+
"peerDependencies": {
216+
"react": "15"
217+
}
218+
}
219+
```
220+
221+
When a user runs `npm install my-react-plugin`, it'll try to install all
222+
three optional dependencies, but 2 of them will always fail by virtue of
223+
the conflicting `react` peer dependency, and they will get one that works
224+
with their system.
225+
226+
Unfortunately, doing this in a naive workspace implementation, where all
227+
`peerDependencies` are deduplicated to the top level means that the project
228+
will necessarily always fail to install with an `ERESOLVE` error, because
229+
the workspace projects cannot all be installed at the same time in the same
230+
place.
231+
232+
Making this more complicated, if the intent of using workspaces is to
233+
ensure that all the workspaces within the project _may_ be used together,
234+
then isolating all conflicting `peerDependencies` would silently
235+
cause problems!
236+
237+
The workaround for this case is for the packages with divergent
238+
peerDependencies to list their peerDependencies in `devDependencies` as
239+
well, so that they may be nested.
240+
241+
## Detailed Explanation
242+
243+
The following expectations should not be silently violated without explicit
244+
user intent.
245+
246+
1. A workspace package that has a dependency on another workspace package
247+
within the same project, should have its dependency met by the
248+
package within the workspace, and not by fetching the dependency from
249+
the registry. If the workspace package does not satisfy the dependency
250+
specification from another workspace package, then a warning will be
251+
displayed.
252+
2. A workspace that has a peer dependency should share the same instance of
253+
that peer dependency with all other workspace projects, unless
254+
marked as a non-peer (ie, dev) dependency as well.
255+
3. A workspace should not be able to load any package that it does not have
256+
an explicit direct dependency or implicit transitive dependency on, with
257+
the exception of dependencies declared at a level higher in the
258+
directory tree.
259+
260+
## Rationale and Alternatives
261+
262+
In the initial naive workspaces implementation in npm, all dependencies are
263+
shared if possible, because workspaces are treated more or less the same as
264+
local `file:` link dependencies.
265+
266+
Thus:
267+
268+
1. Workspaces that depend on another workspace package will resolve to the
269+
other workspace within the project if and only if the dependency is
270+
satisfied, without warning that their dependency is being met by a
271+
version of a workspace package outside the project.
272+
2. Workspaces with conflicting peer dependencies cannot coexist within a
273+
project. (But they do share peer dependencies by default.)
274+
3. Workspaces share many (or all) of their dependencies, since any
275+
dependency that can be deduplicated to the root project level will be,
276+
and can load any dependency installed in the root project.
277+
278+
The `nohoist` option has been proposed, as implemented in
279+
[yarn](https://classic.yarnpkg.com/blog/2018/02/15/nohoist/) and
280+
[lerna](https://github.com/lerna/lerna/blob/main/doc/hoist.md) workspaces.
281+
Downsides of this approach are discussed in **Prior Art** below, but
282+
if possible, it would be good for any implementation to _also_ support
283+
`nohoist`, even if it is not the preferred/canonical way to express the
284+
user's intent.
285+
286+
## Implementation
287+
288+
Implementation of dependency tree layout is out of scope for this RFC.
289+
There are numerous ways to approach the problem technically, which may be
290+
the subject of future RFCs or iterated on separately from the definition of
291+
the intent.
292+
293+
In its simplest form,
294+
295+
* Any edge from a workspace, with a name matching any other workspace
296+
within the project, will be satisfied by a link to that workspace (either
297+
within the workspace itself, or by walking up to the top level).
298+
* For non-peer dependencies, the `PlaceDep` class in `@npmcli/arborist`
299+
will stop at any target location where `target.isWorkspace` is true,
300+
rather than continuing its search to a higher level.
301+
302+
## Prior Art
303+
304+
The `nohoist` option has been proposed, and is implemented in
305+
[yarn](https://classic.yarnpkg.com/blog/2018/02/15/nohoist/) and
306+
[lerna](https://github.com/lerna/lerna/blob/main/doc/hoist.md) workspaces.
307+
308+
For example:
309+
310+
```json
311+
{
312+
"name": "workspace-example",
313+
"version": "1.0.0",
314+
"workspaces": {
315+
"packages": [
316+
"packages/*"
317+
],
318+
"nohoist": [
319+
"**/react-native/**"
320+
]
321+
}
322+
}
323+
```
324+
325+
However:
326+
327+
1. This declaration uses glob patterns to refer to packages within the
328+
dependency graph, which is a UX challenge. (See discussion regarding
329+
the [`overrides` feature](https://github.com/npm/rfcs/pull/129).)
330+
2. The wording of "nohoist" is overly specific to a particular
331+
implementation. That is, the declaration describes the "what" of
332+
package folder tree implementation, instead of the "why" of dependency
333+
graph resolution semantics.
334+
3. Specifying a list of things that are _not_ to be hoisted introduces a
335+
"double-negative" UX problem, which is best avoided.
336+
337+
## Unresolved Questions and Bikeshedding
338+
339+
* Is it reasonable that a workspace with a dependency on a sibling
340+
workspace should _always_ resolve to its sibling? Do we need an escape
341+
hatch for this, like we have for duplicating peerDeps in devDeps?
342+
* This will lead to more duplication in large workspace projects. Are we
343+
comfortable saying "use isolated-mode if you care about that"?

0 commit comments

Comments
 (0)