8000 Build: Add `exports` to package.json, export slim & esm builds by mgol · Pull Request #5255 · jquery/jquery · GitHub
[go: up one dir, main page]

Skip to content

Build: Add exports to package.json, export slim & esm builds #5255

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 31 commits into from
Jul 10, 2023

Conversation

mgol
Copy link
Member
@mgol mgol commented May 18, 2023

Summary

Summary of the changes:

  • add the --esm option to grunt custom, generating jQuery as an ECMAScript module
  • define the exports field in package.json
  • expand node_smoke_tests to test the slim & ESM builds and their various combinations
  • add details about ESM usage to the release package README

I published a test version of jQuery with these changes using jquery-release at:
https://www.npmjs.com/package/@mgol/jquery-exports-test-do-not-use-v7
I also pushed the jquery-dist repo at 4.0.0 with these changes to my fork:
https://github.com/mgol/jquery-dist/tree/exports-test

I also added a test repo using this package to make sure it works:
https://github.com/mgol/jquery-exports-test

Fixes gh-4592

Checklist

  • New tests have been added to show the fix or feature works
  • Grunt build and unit tests pass locally with these changes
  • If needed, a docs issue/PR was created at https://github.com/jquery/api.jquery.com

@mgol mgol added this to the 4.0.0 milestone May 18, 2023
@mgol mgol self-assigned this May 18, 2023
@mgol mgol force-pushed the exports branch 6 times, most recently from dce6641 to b538327 Compare May 22, 2023 15:22
@mgol mgol added Needs review Discuss in Meeting Reserved for Issues and PRs that anyone would like to discuss in the weekly meeting. labels May 22, 2023
@mgol mgol marked this pull request as ready for review May 22, 2023 15:36
package.json Outdated
"type": "commonjs",
"exports": {
".": {
"node": "./dist/jquery.js",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ended up just defining a single entry point for Node.js. This is because our compiled ESM version still only has a default export with the value of module.exports from the regular file - which is just what Node.js does natively if you import a CommonJS module from an ESM one. Using a single file allows to avoid the dual package hazard altogether.

https://nodejs.org/api/esm.html#import-statements

When importing CommonJS modules, the module.exports object is provided as the default export. Named exports may be available, provided by static analysis as a convenience for better ecosystem compatibility.

Copy link
@GeoffreyBooth GeoffreyBooth May 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "node" field should only be used when an export is only usable by Node.js, for example if it contains compiled native extensions. In general we encourage packages to define only require and default whenever possible, to maximize compatibility. In particular, we wouldn’t want ESM Node users getting a CommonJS file defined under "node".

My previous comment #5122 (comment) is still valid; see https://github.com/GeoffreyBooth/js-mjs-mime-type-test. Even in 2023 it’s unsafe to assume that web servers and CDNs will serve .mjs files correctly. Your default export should be a .js file.

Since the most important files, under src, are written in ESM with .js extensions, I would treat .js as meaning “ESM” throughout the codebase for consistency. Therefore the root package.json would get "type": "module" and all the CommonJS files scattered around would get .cjs extensions. Then this block becomes:

  "exports": {
    ".": {
       "require": "./dist/jquery.cjs",
       "default": "./dist/jquery.js"

And likewise for "./slim" and so on.

Copy link
Member Author
@mgol mgol May 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@GeoffreyBooth

Responding to each aspect:

The node field in exports

If I only define require and default I will hit dual package hazard. Per Node.js packages docs, to avoid it one can isolate the state in a CommonJS module and require it in an ESM. This is Node-specific (not suitable for pure ESM envs) so I’d have to add the node entry anyway.

This is what I actually initially aimed at, but then I realized that in our case the ESM module would just have a default export matching module.exports from the CommonJS version, so the ESM wrapper would be useless; Node is properly serving our CommonJS module to ESM consumers fine by default.

The node field is Node-specific in our case, in that it makes Node always serve the CommonJS version to avoid dual package hazard.

.mjs

As for .mjs, why is serving the CommonJS version with the .cjs extension less risky?

Also, right now we produce the same files for browser & Node. The CommonJS version is also the default script version for the browser, it having a .cjs extension would be very unintuitive. I’m also sure it’d break lots of jQuery integrations. On the other hand, both ESM & CommnJS files having the same .js extension won’t work for Node… It seems that avoiding .mjs would require keeping the CommonJS file duplicated, with both the .js and .cjs extension. 😕

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or maybe the solution to avoid .mjs and .cjs is to keep them in separate dist & dist-esm directories and add proper package.json with the type fields?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dual package hazard is only an issue if a package contains internal state. Does jQuery contain any state? Do people register plugins on the jQuery global perhaps, and therefore would be surprised if they existed on jQuery in one module environment but not the other?

Assuming that that is an issue, then you need to choose between these options:

  • Ship the CommonJS version to all Node users, regardless of whether the Node application is written in CommonJS or ESM. This might lead to bundlers including all of jQuery rather than tree-splitting the ESM version, and it might annoy the developer who’s writing ESM to see CommonJS in the stack trace. Neither of these are showstoppers, but they’re worth considering.
  • Ship only the ESM version, period, joining the list of packages that have dropped CommonJS support in their latest major versions. Obviously this would greatly annoy any Node developers writing CommonJS applications.
  • Ship both versions where Node ESM users get the ESM version and Node CommonJS users get the CommonJS version. You would probably have to add a warning in the docs telling people not to add custom properties to the jQuery object or other exports, that they expect to see across module systems.

If the hazard is a real concern, then sure, maybe the first option is the least bad one.

As for .mjs, why is serving the CommonJS version with the .cjs extension less risky?

Because web servers and CDNs aren’t serving the CommonJS version; that version is only for Node or bundlers, who would be loading it from files (and which all have support for the .cjs extension; all Node versions treat unknown extensions as CommonJS, so it works back to the beginning). Whereas the version for use in browsers needs to be served with the correct MIME type, and per https://github.com/GeoffreyBooth/js-mjs-mime-type-test even in 2023 many big open source webservers like Nginx and Apache don’t serve .mjs with the correct MIME type by default. Or put another way, there’s no benefit to .mjs but there’s a big downside to this potential incompatibility.

right now we produce the same files for browser & Node

Aren’t you producing both a browser ESM version and a browser AMD version? So it’s the browser AMD version that is identical to the Node CommonJS version?

Yes, you could just output it twice, like jquery.amd.js and jquery.cjs, and the contents of the two versions happen to be identical. That wouldn’t be terrible. It probably would be preferable for them to not be identical, as the .cjs version doesn’t need some of the AMD stuff like checking if require is defined; it can just assume that it’s in a CommonJS environment.

You could also do something like put this combined AMD/CommonJS file in a subfolder like dist/browser-amd-and-node-commonjs/jquery.js and add dist/browser-amd-and-node-commonjs/package.json with "type": "commonjs". Then it’s a little more explicit that this file is being reused. Personally I would just do the first option, outputting it twice, so that you have the opportunity to optimize each one a bit rather than having checks that are only needed since you’re reusing the same file in two very different environments.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@GeoffreyBooth I wonder - do we need to provide a default export at all? The CommonJS version would have one but if we keep different exported tokens between CommonJS & ESM versions anyway, can we just go all the way and only export named $ & jQuery?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to provide a default export at all?

For backward compatibility, I would assume you need to provide it; aren't people writing import jQuery from 'jquery' today? And you're forced to support that if you support Node via the CommonJS wrapper approach; so then not allowing that syntax in pure ESM environments would seem likely to cause confusion.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, right, I forgot about a lot of the code relying on bundlers that would auto-switch to the ESM version & break.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@GeoffreyBooth I'm working on updates right now. I'm missing why we need the production section for the node condition. I understand why it's needed outside of Node.js and so that we need the production, development & module fields there but why in Node?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason to include it for Node is that so if someone wants to use the minified build in Node, for example for server side rendering they want to match client behavior, there's a way to do so. It's arguably optional though.

Comment on lines +18 to +25
var jQueryOrJQueryFactory = typeof window !== "undefined" && window.document ?
jQueryFactory( window, true ) :
function( w ) {
if ( !w.document ) {
throw new Error( "jQuery requires a window with a document" );
}
return jQueryFactory( w );
};
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking whether it'd be better to extract this factory logic to a separate entry point: jquery/factory, and have the default one just assume the global window. This would simplify these headers.

The current setup doesn't let you freely use the factory in all scenarios anyway - if you load any files depending on jQuery via CommonJS - which is common, through UMD - then that file depends on "jquery" resolving to an actual jQuery instance, not a factory. In effect, you need to set the global window anyway and the factory remains useless.

What is useful about this wrapper is the window encapsulation - if such a global is present, browser globals are all taken from window instead of global variables. This is what unlocks libraries like JSDOM. When I mentioned to factory to Domenic some time ago, he didn't even know about the factory way of loading jQuery. 🙃

The problem with an additional entry point is we'd also want to expose the factory in slim version - which would expand from the current two entry points: jquery & jquery/slim to 4: jquery, jquery/slim, jquery/factory, jquery/slim-factory.

// This accentuates the need for the creation of a real `window`.
// e.g. var jQuery = require("jquery")(window);
// See ticket trac-14549 for more info.
var jQueryOrJQueryFactory = typeof window !== "undefined" && window.document ?
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no point in falling back to this as we do in the main wrapper. There, it made sense as this has some meaning in CommonJS. In ESM, it's just undefined at the top level.

src/package.json Outdated
@@ -0,0 +1,3 @@
{
"type": "module"
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This allows src/ files to retain the .js extension, despite being ECMAScript modules. Node.js recognizes they are ESM in such a case.

@@ -0,0 +1,14 @@
import { JSDOM } from "jsdom";

import { ensureJQuery } from "./lib/ensure_jquery.mjs";
Copy link
Member Author
@mgol mgol May 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that all of these use the .mjs extension. This could be avoided by adding a simple package.json:

{
  "type": "module"
}

to the test/node_smoke_tests/module/ folder. However, some of node smoke test runs actually test exports from within the package:
https://nodejs.org/api/packages.html#self-referencing-a-package-using-its-name
That way, we can easily test whether what we define via exports really works.

The problem is, this feature requires the closest package.json to the current module so a root one in our case. This means we cannot add such a simple package.json to test/node_smoke_tests/module/.

These are just tests run in Node.js, nothing to be consumed externally, so I think it should be fine.

package.json Outdated
"test:browser": "grunt && grunt karma:main",
"test:esmodules": "grunt && grunt karma:esmodules",
"test:amd": "grunt && grunt karma:amd",
"test:no-deprecated": "grunt test:prepare && grunt custom:-deprecated && grunt karma:main",
"test:selector-native": "grunt test:prepare && grunt custom:-selector && grunt karma:main",
"test:slim": "grunt test:prepare && grunt custom:slim && grunt karma:main",
"test": "npm run test:slim && npm run test:no-deprecated && npm run test:selector-native && grunt && grunt test:slow && grunt karma:main && grunt karma:esmodules && grunt karma:amd",
"test:node_smoke_tests:slim": "grunt node_smoke_tests:commonjs:./dist/jquery.slim.js && grunt node_smoke_tests:commonjs:jquery/slim",
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This first run node smoke tests by running require( "FULL/PATH/TO/dist/jquery.slim.js" ) and then by running require( "jquery/slim" ); the latter part goes through exports defined in this package.json, which is a nice test that this setup works.

The same is done for other test:node_smoke_tests:* scripts.

@@ -0,0 +1,15 @@
"use strict";
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous node smoke tests have been moved to the commonjs folder (with some modifications), their ESM versions are in the module folder.

import { getJQueryModuleSpecifier } from "./lib/jquery-module-specifier.mjs";

const jQueryModuleSpecifier = getJQueryModuleSpecifier();
const { default: jQueryFactory } = await import( jQueryModuleSpecifier );
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

top-level await is available unflagged in Node.js since 14.8.0:
https://nodejs.org/en/blog/release/v14.8.0

We now test on Node.js 10, 16, 18 & 20 and 10 is on that list only because our Jenkins runs it. But node smoke tests are skipped in Node 10 now.

@mgol
Copy link
Member Author
mgol commented May 22, 2023

@aduh95 @GeoffreyBooth would you be able to review this version? You previously reviewed my POC at #5122.

@mgol
Copy link
Member Author
mgol commented May 22, 2023

A review from @guybedford would also help if possible.

@mgol
Copy link
Member Author
mgol commented May 22, 2023

Fixes gh-4592

Copy link
Contributor
@guybedford guybedford left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is jQuery itself stateful so that the dual package hazard will be a real issue? The default export seems fine as they are very much designed for the jQuery pattern. So long as the ESM version does export default jQuery.

@GeoffreyBooth
Copy link

Is jQuery itself stateful so that the dual package hazard will be a real issue?

Yes, people register jQuery plugins that attach themselves as properties to the jQuery object. I think that would be considered state in that if someone does jQuery.myPlugin = in one module system, that added property wouldn’t exist in the other system unless we do the CommonJS wrapper approach.

@guybedford
Copy link
Contributor

The "module" condition is probably recommended then.

@timmywil timmywil removed the Discuss in Meeting Reserved for Issues and PRs that anyone would like to discuss in the weekly meeting. label Jul 10, 2023
@mgol mgol removed the Needs review label Jul 10, 2023
@mgol mgol merged commit 8be4c0e into jquery:main Jul 10, 2023
mgol added a commit to mgol/jquery that referenced this pull request Jul 10, 2023
Summary of the changes:
* expand `node_smoke_tests` to test the full & slim builds
* run `compare_size` on all built minified files; don't run it anymore on
  unminified files where they don't provide lots of value

The main goal of this change is to make it easier to compare sizes of both the
full & slim builds between the `3.x-stable` & `main` branches.

Ref jquerygh-5255

(partially cherry-picked from commit 8be4c0e)
@mgol mgol deleted the exports branch July 10, 2023 18:19
mgol added a commit that referenced this pull request Jul 10, 2023
Summary of the changes:
* expand `node_smoke_tests` to test the full & slim builds
* run `compare_size` on all built minified files; don't run it anymore on
  unminified files where they don't provide lots of value

The main goal of this change is to make it easier to compare sizes of both the
full & slim builds between the `3.x-stable` & `main` branches.

Closes gh-5291
Ref gh-5255

(partially cherry-picked from commit 8be4c0e)
mgol added a commit to mgol/jquery-migrate that referenced this pull request Mar 31, 2025
The Migrate setup is simpler than Core as it doesn't have the slim or factory
versions, but the core ideas are similar.

Ref jquery/jquery#5255
Ref jquery/jquery#5429
mgol added a commit to mgol/jquery-migrate that referenced this pull request Mar 31, 2025
The Migrate setup is simpler than Core as it doesn't have the slim or factory
versions, but the core ideas are similar.

Ref jquery/jquery#5255
Ref jquery/jquery#5429
mgol added a commit to mgol/jquery-migrate that referenced this pull request Mar 31, 2025
The Migrate setup is simpler than Core as it doesn't have the slim or factory
versions, but the core ideas are similar.

Fixes jquerygh-524
Ref jquery/jquery#5255
Ref jquery/jquery#5429
mgol added a commit to mgol/jquery-migrate that referenced this pull request Apr 1, 2025
The Migrate setup is simpler than Core as it doesn't have the slim or factory
versions, but the core ideas are similar.

Fixes jquerygh-524
Ref jquery/jquery#5255
Ref jquery/jquery#5429
mgol added a commit to jquery/jquery-migrate that referenced this pull request Apr 14, 2025
The Migrate setup is simpler than Core as it doesn't have the slim or factory
versions, but the core ideas are similar.

Fixes gh-524
Closes gh-566
Ref jquery/jquery#5255
Ref jquery/jquery#5429
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Development

Successfully merging this pull request may close these issues.

ESM distribution / exports in package.json
4 participants
0