jsr-io / jsr

The open-source package registry for modern JavaScript and TypeScript
https://jsr.io
MIT License
2.34k stars 102 forks source link

How shoud you indicate that your package has peer dependencies? #332

Open adamzerner opened 6 months ago

adamzerner commented 6 months ago

Do you need a package.json to do this? Is there a way to indicate peer dependencies without using package.json? Maybe using jsr.json somehow?

Context: I would like to publish @adamzerner/rfu-react to JSR as @rfui-library/rfui. It is currently published on NPM and has the following in package.json:

"peerDependencies": {
  "react": "^18.0.0",
  "react-dom": "^18.0.0"
},

I'd like to get rid of package.json but am not sure how I'd specify those peer dependencies if I did.

lucacasonato commented 6 months ago

You can specify react as a regular dependency with a very loose version constraint instead.

adamzerner commented 6 months ago

You can specify react as a regular dependency with a very loose version constraint instead.

@lucacasonato Are you implying that doing that will provide the functionality that NPM's peer dependencies provides? If so, I don't understand why that'd be the case.

Taking my situation as an example, if I had a package.json with:

"dependencies": {
  "react": "^18.0.0",
  "react-dom": "^18.0.0"
},

then when someone tries to use my package by doing npx jsr add @rfui-library/rfui, since my package uses "dependencies" instead of "peerDependencies", I don't see how we'd get the behavior of "peerDependencies".

If I loosened the version constraint, to something like >=14, I only intend to support ^18.0.0 so I'd have to mention that in the docs or something. But that'd kinda contradict the "react": ">=14" in package.json and thus be a bad developer experience IMO.

lucacasonato commented 6 months ago

I am unclear what you mean. In NPM, the behavior of peerDependencies in 2024 is pretty much identical to dependencies. When peerDependencies was first introduced in npm, there was a functional difference. Today (and any time since npm 7), they have been mostly identical in behavior.

The 18.x constraint seems fine - you may want to go a bit looser and also allow 17.x for example. You could have a constraint like >=17.0.0 <19.0.0

adamzerner commented 6 months ago

In NPM, the behavior of peerDependencies in 2024 is pretty much identical to dependencies. When peerDependencies was first introduced in npm, there was a functional difference. Today (and any time since npm 7), they have been mostly identical in behavior.

That doesn't seem true to me.

Suppose that I published @adamzerner/rfui-react to JSR as @rfui-library/rfui (what I'm intending to do) and set a normal (not peer) dependency of React v18.x. And suppose (for whatever reason) the host application is still on React v18.1.0 (the most recent version as of the time of this posting is v18.2.0).

When the user of the host library runs npm install it will lead to two different versions of React being used: v18.1.0 by the host library and v18.2.0 for @rfui-library/rfui. There are two main issues that I see with this:

  1. The fact that two versions are in use could lead to bugs and unexpected behaviors.
  2. Having a second version increases the bundle size.

On the other hand, if @rfui-library/rfui was able to set a peer dependency of React v18.x, instead of downloading it's own v18.2.0 version of React, @rfui-library/rfui would just use the host's v18.1.0 version of React. This has the benefits of:

  1. There is only one version of React in use.
  2. The bundle size is kept to a minimum.
lucacasonato commented 6 months ago

@adamzerner That is not the case. In the case you describe, where a root package depends on React, and sibling of this dependency depends on React too (see graph below), you would get exactly one copy of React (solved to 18.1.0), as long as the constraint for React on the peer dependency is satisfiable by the initial dependency on React from root.

graph TD;
    Root-->react1["react"];
    Root-->plugin-using-react;
    plugin-using-react-->react2["react"];

This is the case because dependencies are always solved top down, and as long as you have a constraint at the top level that satisfies all constraints at lower levels for the same package, your package will never be duplicated.

If your constraint at the top level does not satisfy all constraints at lower levels for the same package, the package will be duplicated or you will be warned or you one of the constraints will be ignored - what exactly happens depends on the exact package manager (npm, pnpm, yarn, bun, deno) being used.

The misconception here is that every constraint will try to independently solve for the highest satisfiable version - however, this is not the case for either dependencies or peerDependencies. If an already resolved version can satisfy a constraint even if it is not the highest version that would satisfy that constraint, that version is still used.

lucacasonato commented 6 months ago

To elaborate a bit more on this example. Imagine these are the package.json files for the root package, and plugin-using-react. In this case, npm will solve to react@18.1.0 only, even though peerDependencies is nowhere to be seen :)

// root/package.json
{
  "dependencies": {
    "react": "18.1.0",
    "plugin-using-react": "1.0.0"
  }
}
// plugin-using-react/package.json
{
  "name": "plugin-using-react",
  "version": "1.0.0",
  "dependencies": {
    "react": "^18"
  }
}
adamzerner commented 6 months ago

Huh. I didn't realize that.

Thanks for taking the time to explain this. This is my first time being a library author and I'm realizing that there are things about NPM and dependency management that I'm lacking awareness of.

I'm still confused about various things, but StackOverflow is the appropriate place to resolve these confusions so I’ll continue the conversation there.

I think this GitHub Issue can be closed.

adamzerner commented 6 months ago

@lucacasonato Actually, what about the following situation?

I set a regular dependency on React 18 in @rfui-library/rfui:

"dependencies": {
  "react": "^18.0.0",
  "react-dom": "^18.0.0"
},

Someone is building cool-app and depends on @rfui-library/rfui. They are only on React 16. They're using npm v10 and have the following:

"dependencies": {
  "react": "^16.0.0",
  "@rfui-library/rfui": "^1.0.0"
},

In this case, my understanding is that two copies of React will be installed and the node_modules folder will look something like this after running npm install:

node_modules
  react (v16)
  @rfui-library/rfui
    node_modules
      react (v18)

Furthermore, running npm install won't produce any warning or error messages. It will just install the second copy of React.

On the other hand, if I set a peer dependency on React 18 in @rfui-library/rfui, my understanding is that when they run npm install, they will get a different outcome. Instead of the two versions of React, npm will encounter an unresolvable peer dependency conflict when trying to install @rfui-library/rfui, print an error, and exit.

To me this seems like a practical situation where there is a meaningful difference between peer and regular dependencies for someone using the latest version of npm. If so, it seems to me that it would be important for JSR to offer the ability to set peer dependencies to meet the needs of library authors who prefer the experience that peer dependencies provide library users.

From what I can tell, most people seem to think the experience that peer dependencies provides is a better one since it makes it clear that something has gone wrong. In npm versions 4, 5 and 6 it would print a warning and continue. In npm 7 and onwards it will take a stronger stance and print an error and exit. But most importantly, in both scenarios it will provide some sort of indication to the user that something dangerous and undesirable is happening. On the other hand, with regular dependencies it will do something dangerous in installing a second version of a library without providing an indication that something dangerous happened.

lucacasonato commented 6 months ago

@adamzerner Unfortunately the behaviour of peerDependencies varies very widely between different package managers, and versions of the same package manager. So far, we have only talked about npm 1-3, 4-6, and 7+. The behaviour here in npm 7+ for the use case you described is certainly desirable in some cases. However you can not use peerDependencies as a general guard against this issue - you need to perform runtime checking to ensure that multiple copies of React are not loaded. For example, in Yarn (all versions), peer dependency conflicts do not error, and instead print out a teeny tiny warning during install which most people will not see, and run into the runtime issue anyway.

As with many JS issues, your only reliable workaround here is a runtime check to ensure that the behaviour you are expecting is indeed the behaviour you are getting. Ie not two versions of React are loaded.

I'll check with the team internally if there is anything nice we can do here, but I have a feeling that it is currently in a state of "too broken to fix". Most other registry and package management ecosystems get away with not having any form of specific "peer dependencies", and it works totally fine.

adamzerner commented 6 months ago

@lucacasonato

As with many JS issues, your only reliable workaround here is a runtime check to ensure that the behaviour you are expecting is indeed the behaviour you are getting. Ie not two versions of React are loaded.

Even if you're using npm? I don't see why that would be the case with npm.

For example, in npm 7+, suppose you have a dependency on React and your other dependencies have peer dependencies on React. My understanding is that you will never end up with two versions of React. When you npm install, if the peer dependency can be met it will be met and there will be one version of React. If the peer dependency can't be met it will print an error message and exit.

And then in npm 6 my understanding is that it's the same behavior if the peer dependency can be met, and if it can't be met it will print a warning and continue trying to install other dependencies (as opposed to erroring and exiting), but not install a second version of React.

So then I'm not seeing a scenario where you can end up with two versions of React if you're using npm and your dependencies peer depend instead of regular depend on React.

I think this is an important question because if people who use npm as a package manager can trust that peer dependencies avoid the risk of duplicate packages, it would be meaningfully helpful for JSR to support peer dependencies.

I hear ya that other package managers don't play nicely with peer dependencies, but maybe JSR can basically support peer dependencies if npm is the package manager and pretend they're not there if npm is not the package manager? If so, given how popular npm is as a package manager, that seems like it'd add a meaningful amount of value.

Most other registry and package management ecosystems get away with not having any form of specific "peer dependencies", and it works totally fine.

Hm, maybe. I think issues aren't too common, but when they occur they are a real pain and a real timesink. Guesstimating, maybe they occur one or twice a year and take a few days to investigate and resolve? Not the end of the world, but IMHO it would be a nice win if it could be reliably avoided.