Skip to content

🚀 Feature: Enforce order of imports and exports #928

Open
@GeorgeTaveras1231

Description

@GeorgeTaveras1231

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 #.

{
  "imports": {
    "#private-alias": {
      "import": "./path/to/private.mjs",
      "require": "./path/to/priate.cjs"
    } // <<--- condition object
  }, // <<--- subpath import object
  "exports" {
    "./sub": {
      "import": "./sub.mjs",
      "require": "./sub.cjs"
    } // <<--- condition object
  } // <<--- subpath export object
}

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

  1. Sort alphabetically
  2. 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 before rollup as Vite uses Rollup under the hood.

Edge-Runtime

keys:

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 before bun, as this is a special Bun condition.
  • deno and bun must come before browser, 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 and sass conditions
  • style 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 formats
  • module must come before import. 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 for import modules
  • import must come before module-sync. import supports both sync and async ES Modules, module-sync can be used to require(ESM)
  • module-sync must come before require. The primary purpose of module-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

  1. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    status: in discussionNot yet ready for implementation or a pull requesttype: featureNew enhancement or request 🚀

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions