Description
Bug Report Checklist
- I have tried restarting my IDE and the issue persists.
- I have pulled the latest
main
branch of the repository. - I have searched for related issues and found none that matched my issue.
Overview
This is a feature request and proposal for handling the exports
and imports
keys
Additional Info
Context
At the moment, the sort-collections
rule improperly enforces alphabetical order on top-level condition objects in the exports
and imports
(if specified) keys alphabetically. Sorting condition objects alphabetically causes issue because resolvers that support import/export conditions use the first matching condition. This is especially bad when running the Eslint auto-fixer, which can introduce production issues for the consumers of the package.
In addition, enforcing a consistent order of the condition objects has a high-impact potential for teams maintaining large amounts of package.json files, supporting multiple code variants (syntaxes, environments, runtimes, etc). The more conditions you support, the easier it is to accidentally create a dead-code path (a condition that will never be met) and to accidentally break a consumer that depends on a particular set of conditional exports.
Proposal
eslint-plugin-package-json
should enforce a consistent order of the exports
and imports
keys,
It should implement an order of the conditions object based on openly available knowledge of "known" conditions while allowing developers to specify custom conditions. In addition, it should sort the subpath objects to make them more readable.
Before explaining the proposal, I'll leave a simple description of the terms I will use in the rest of the proposal.
Terms
subpath and condition objects
Subpath exports objects are the objects that describe the package entry-points, where the keys start with .
.
Subpath import objects are the objects that describe the private path aliases, where the keys start with #
.
Condition objects describe the conditions supported by the entrypoint, where the keys do not start with .
or #
.
dead-code paths
Conditions in the condition object that will never be met.
Subpath Object Order
The Subpath Object is the easier part of this feature, so I will detail it first.
Goals
The goal is to make these objects more readable. I want to enforce an order that reads naturally and resembles a logical order when using wildcards (*
).
Solution
- Sort alphabetically
- Increase comparison weight of wildcards (
*
) to move them after concrete subpaths.
Examples
Invalid
Note: Would be the same for
import
- except with#
instead of./
{
"./b/*": "./b/*.js",
"./b/sub": "./b/sub-override.js",
"./b": "./b.js",
"./a": "./a.js",
"./c": "./c.js"
}
Valid
Note: Would be the same for
import
- except with#
instead of./
{
"./a": "./a.js",
"./b": "./b.js",
"./b/sub": "./b/sub-override.js",
"./b/*": "./b/*.js",
"./c": "./c.js"
}
Condition Object Order
The condition object should be organized to avoid common problems.
Goals
The primary goal of the condition object order is to minimize dead-code paths. Dead-code paths are created when more than one condition is met by the resolver, but a more specific condition is nullified by a less specific condition.
The simplest example of this is when using the default
condition before any other condition. The default
condition is the ultimate fallback condition, so every resolver will match it regardless of any other matching condition
Example:
{
"default": "./main.default.js",
"import": "./main.mjs" // <<--- ❌ Dead-code path - this condition will never be reached because `default` always matches
}
A more nuanced example is the following:
{
"import": "./main.mjs",
"browser": "./main.browser.js" // <<-- ❌ Possibly dead-code
}
In the last example, the browser
condition may be a dead-code path. The browser
condition is generally used by bundlers (like Webpack, Rollup, etc), but they generally also use the import
condition. So even if the bundle is targeting browsers, the browser
condition will not be used.
Some condition pairs have a strict order requirement. For example, module
must come before import
, import
must come before module-sync
, module-sync
must come before require
- the relative order of these conditions should be enforced by our solution.
In some cases, there isn't a strict order requirement between known conditions, however, the other goal of the proposed condition order is to group similar conditions together.
For example, the rollup
condition used by the Rollup bundler and the webpack
condition used by Webpack don't have any overlapping scenarios, so their relative order does not matter; However, it would be nice to have these two next to each other as they are both conditions for bundlers.
Constraints
In my research, I've found 40 well-known conditions that are used by various open source tools and services, so the biggest challenge I've found is creating a widely applicable model to apply to each condition when determining their relative order.
I've concluded that it is impossible (or very difficult) to create a unified model for all of the conditions, as there are many configurations that produce valid packages without dead-code paths.
Take for example the require
and browser
conditions. require
is used by resolvers looking for a CJS module and browser
is used by resolvers in bundlers when targeting browsers.
Both of the following configurations are valid:
{
"./browser-first": {
"browser": "./browser-first.browser.js",
"require": "./browser-first.cjs"
},
"./require-first": {
"require": "./require-first.cjs",
"browser": "./require-first.browser.js",
}
}
In the first path (./browser-first
) a bundler may use the browser
condition if targeting a browser, but will use the require
condition when targeting Node.js. The node
CLI will also use the require
condition.
In the second path (./require-first
) a bundler that does not support require
will use the browser
condition if it is configured to target a browser. However, Node.js or a bundler configured to support commonjs will use the require
condition.
This is to say, there isn't a way to globally enforce a relative order between these conditions. But it is still too easy to accidentally create dead-code paths.
Solution
My proposed solution to reduce the risk of dead-code paths while enforcing a correct order is multi-faceted.
1. Enforce categorized condition objects
To avoid accidental dead-code paths, we can add a rule that limits the use overlapping conditions in the same object - forcing developers to choose mutually exclusive conditions. The mutually exclusive conditions can be defined as condition groups, which can be extended if necessary.
Example:
Configuration example
/// rule options
{
"conditionGroups": {
"targetRuntime": [
"browser",
"node",
],
"referenceSyntax": [
"module",
"require",
"import"
]
}
}
This configuration communicates that you can only use browser
and node
in the same object, and module
, require
and import
in the same object, but you cannot mix-match between groups. In addition, the order of the condition name arrays should be the enforced key order within the condition object. So module
must come before require
, etc.
/// ❌ Invalid configuration - category mix-match
{
"browser": "./main.browser.js",
"module": "./main.module.js",
"import": "./main.mjs",
"require": "./main.cjs"
}
/// ❌ Invalid configuration - invalid order
{
"browser": "./main.browser.js",
"default": {
"require": "./main.cjs", /// ❌ module should come before "require"
"module": "./main.module.js",
"import": "./main.mjs"
}
}
/// ✅ Valid configuration - no mix-match and proper order
{
"browser": "./main.browser.js",
"default": {
"module": "./main.module.js",
"import": "./main.mjs",
"require": "./main.cjs"
}
}
2. Fallback conditions and sorting exceptions
The treatment of condition categorization should not apply to a list of known conditions that can be considered fallback conditions.
default
condition
The default
condition can be added to any condition object and must always be at the end.
types
and types@{typesVersionsSelector}
The types
and types@{typesVersionsSelector}
conditions can also be added to any condition object and should generally be at the top (though there is some flexibility with this because of how typescript works).
The types@{typesVersionsSelector}
condition should come before the types
condition and should be sorted using a version range sorting algorithm.
/// ❌ Invalid configuration - invalid order
{
"types": "./types.d.ts",
"types@>4.1": "./types.4.1.d.ts"
}
/// ❌ Invalid configuration - invalid order
{
"types@>4.1": "./types.4.1.d.ts",
"types@>4.2": "./types.4.2.d.ts", // ❌ dead-code path 4.2 is matched by 4.1 above
"types": "./types.d.ts"
}
/// ✅ Valid configuration - proper order
{
"types@>4.2": "./types.4.2.d.ts",
"types@>4.1": "./types.4.1.d.ts",
"types": "./types.d.ts"
}
Handling this scenario will require a non-trivial solution to parse the version ranges and sort them based on specificity.
3. Provide default groups
Most conditions used in the wild are well known conditions with well established semantics and relationships with other conditions. This can be captured in the rule's default configurations. These are the groups and conditions I've identified in my research:
Note: the intention is to enforce the order they are listed in their corresponding lists. The order is purely aesthetic in some cases, in other cases, it is to avoid dead-code paths, if there is some overlap between the conditions.
Environment
keys:
test
development
production
Note: Order is aesthetic
Bundler
keys:
vite
rollup
webpack
Note: Order is MOSTLY aesthetic. I suspect that
vite
must come beforerollup
as Vite uses Rollup under the hood.
Edge-Runtime
keys:
azion
(By Azion)edge-light
(By Vercel)edge-routine
(By Alibaba Cloud)fastly
(By Fastly)lagon
(By Lagon [Now Vercel])netlify
(By Netlifywasmer
(By Wasmer)workerd
(By Cloudflare)
Note: Order is alphabetic
Edge-runtimes can overlap with many of the target runtimes (Example: a node edge, a deno edge). Therefore, they must be isolated in their own group.
Server Variant
keys:
react-server
It may not be obvious why
react-server
is in its own group. This is because it can overlap with many of the "Target Runtime", "Reference Syntax", and "Edge Runtime" conditions.
Target Runtime
keys:
macro
bun
deno
browser
electron
kiesel
node-addons
node
moddable
react-native
worker
worklet
Note: Order is aesthetic with few exceptions.
macro
must come beforebun
, as this is a special Bun condition.deno
andbun
must come beforebrowser
, since both Deno and Bun can be used to create a browser bundle.
Reference Syntax
keys:
svelte
asset
sass
stylus
style
script
module
import
module-sync
require
Note: Order is strict for a couple reasons
- Svelte can resolve resources in multiple syntaxes (sass, style, module)
- Sass can resolve
style
andsass
conditionsstyle
is used to resolve CSS Modules. CSS Modules are JS modules under the hood. Should come before other JS syntaxes to avoid resolving a JS module.script
condition may resolve to JS that can be downloaded by a<script />
tag. Should come before other JS formatsmodule
must come beforeimport
.import
is node's standard for async ES Modules (ESM files that may have top-level await).module
is used by bundlers primarily and the semantics are more flexible than Node's forimport
modulesimport
must come beforemodule-sync
.import
supports both sync and async ES Modules,module-sync
can be used torequire(ESM)
module-sync
must come beforerequire
. The primary purpose ofmodule-sync
is to move away from the CJS format, package developers can export sync modules that can be required.
4. Support for unknown conditions
It's not possible to predict all the possible conditions that will exist in the future or conditions that are project specific. In addition, we can't predict the relationship between those conditions and well established/known conditions.
We can approach this by restricting the use of unknown conditions by default, but giving developers the choice to configure the rule, to add their own conditionGroups
or extend the default conditions in the predefined groups.
Example configuration:
{
"conditionGroups": {
"...": true, /// Extend default groups,
"referenceSyntax": [
"...", /// <<-- extend default list
"syntax-from-the-future" /// <<-- Suffix default list
],
"serverVariant": [
"react-server@3" /// <<-- Prefix default list
"...", /// <<-- extend default list
],
"targetRuntime": [ /// <<-- Override default list, removes ability to use other conditions in default
"browser",
"node",
"new-js-runtime"
]
}
}
Additional requirements
- Recursive: The rule check must be applied recursively, as condition objects can be nested.
Drawbacks
These are the drawbacks I've identified. I may edit this list as we discover more drawbacks.
More verbose export objects.
Developers will be forced to create more verbose objects. This can be tedious in small/simple projects. In larger projects, this is offset by the benefit of clarify.
Example
{
"import": "./main.mjs",
"browser": "./main.browser.js"
}
Will have to be written as
{
"import": "./main.mjs",
"default" :{
"browser": "./main.browser.js"
}
}
or
{
"browser": {
"import": "./main.browser.mjs",
"default": "./main.browser.js"
},
"default" : "./main.mjs"
}
Resources
I've used the following resources to come up with this proposal