Skip to content
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

Add new package @wordpress/lazy-import for lazily installed packages #22684

Merged
merged 12 commits into from
Jun 3, 2020

Conversation

aduth
Copy link
Member

@aduth aduth commented May 27, 2020

This pull request seeks to introduce a new package @wordpress/lazy-import, which can be used to dynamically install modules on-demand. The proposed usefulness here is in cases where modules are used in ad-hoc scripts (see included changes for benchmarking dependencies) or in cases where the dependency is only used in particular logic paths which may not always be encountered, or at least not frequently enough to warrant the up-front cost of the install. @wordpress/scripts is a good candidate for this, where this package is very similar to what was done in #20215, but extrapolated for any dependency.

As mentioned, the changes include removal of top-level dependencies for various shallow-equals implementations which are used only for the (rarely-run) benchmarking script bundled with @wordpress/is-shallow-equal.

Future potential usage could include lazily importing individual dependencies of @wordpress/script on a per-command basis (e.g. install Jest only if and when running test-unit, Prettier when running format-js, ESLint when running lint-js, etc).

Implementation Notes:

The lazy-require package served as some inspiration for this one. However, aside from the fact that there are some issues with it (bevry-archive/lazy-require#48), it does not support an option to specify a version of the package to use. This can be problematic, since it could potentially mean that a later major version update of a package could break expected usage.

With this implementation, the usage is such that you can call it with an argument string as would be passed to npm install, including an optional version specifier:

await lazyImport( 'fbjs@^1.0.0' );

This is partly inspired by RunKit, which works this way when using require:

https://runkit.com/embed/qxtd3i7j9org

Internally, it uses the same modules used by NPM to parse and validate the version specifier, so it should act exactly as one should expect as if the string is passed to npm install.

Normally, it is not possible to install multiple versions of the same package to a local project. To work around this limitation, the implementation (ab)uses npm aliases, where a unique name is generated using in the input argument.

For illustration purposes, given an input shallow-equal@^1.2.1, it behaves like:

npm install --no-save @wordpress/lazy-import.5ab856344c555e50779f56b4cd1f2e02@npm:shallow-equal@^1.2.1

...where 5ab856344c555e50779f56b4cd1f2e02 is an md5 hash of the input shallow-equal@^1.2.1.

Testing Instructions:

Verify that the benchmarking script still runs without issue, even after clearing node_modules and installing with the benchmark dependencies removed:

node packages/is-shallow-equal/benchmark/index.js

The first run may take some additional time since the dependencies must be installed on-demand. Subsequent runs should start faster (though still delayed, since benchmarking itself takes some time to run).

@aduth aduth added [Type] Performance Related to performance efforts npm Packages Related to npm packages [Type] New API New API to be used by plugin developers or package users. labels May 27, 2020
@github-actions
Copy link

github-actions bot commented May 27, 2020

Size Change: 0 B

Total Size: 1.12 MB

ℹ️ View Unchanged
Filename Size Change
build/a11y/index.js 1.14 kB 0 B
build/annotations/index.js 3.62 kB 0 B
build/api-fetch/index.js 3.4 kB 0 B
build/autop/index.js 2.82 kB 0 B
build/blob/index.js 620 B 0 B
build/block-directory/index.js 6.74 kB 0 B
build/block-directory/style-rtl.css 892 B 0 B
build/block-directory/style.css 892 B 0 B
build/block-editor/index.js 106 kB 0 B
build/block-editor/style-rtl.css 11.4 kB 0 B
build/block-editor/style.css 11.4 kB 0 B
build/block-library/editor-rtl.css 7.87 kB 0 B
build/block-library/editor.css 7.88 kB 0 B
build/block-library/index.js 126 kB 0 B
build/block-library/style-rtl.css 7.69 kB 0 B
build/block-library/style.css 7.68 kB 0 B
build/block-library/theme-rtl.css 684 B 0 B
build/block-library/theme.css 686 B 0 B
build/block-serialization-default-parser/index.js 1.88 kB 0 B
build/block-serialization-spec-parser/index.js 3.1 kB 0 B
build/blocks/index.js 48.2 kB 0 B
build/components/index.js 193 kB 0 B
build/components/style-rtl.css 19.5 kB 0 B
build/components/style.css 19.5 kB 0 B
build/compose/index.js 9.32 kB 0 B
build/core-data/index.js 11.4 kB 0 B
build/data-controls/index.js 1.29 kB 0 B
build/data/index.js 8.44 kB 0 B
build/date/index.js 5.47 kB 0 B
build/deprecated/index.js 771 B 0 B
build/dom-ready/index.js 568 B 0 B
build/dom/index.js 3.11 kB 0 B
build/edit-navigation/index.js 8.25 kB 0 B
build/edit-navigation/style-rtl.css 878 B 0 B
build/edit-navigation/style.css 876 B 0 B
build/edit-post/index.js 302 kB 0 B
build/edit-post/style-rtl.css 5.43 kB 0 B
build/edit-post/style.css 5.43 kB 0 B
build/edit-site/index.js 14.1 kB 0 B
build/edit-site/style-rtl.css 2.96 kB 0 B
build/edit-site/style.css 2.96 kB 0 B
build/edit-widgets/index.js 8.83 kB 0 B
build/edit-widgets/style-rtl.css 2.4 kB 0 B
build/edit-widgets/style.css 2.4 kB 0 B
build/editor/editor-styles-rtl.css 425 B 0 B
build/editor/editor-styles.css 428 B 0 B
build/editor/index.js 44.7 kB 0 B
build/editor/style-rtl.css 4.26 kB 0 B
build/editor/style.css 4.27 kB 0 B
build/element/index.js 4.65 kB 0 B
build/escape-html/index.js 733 B 0 B
build/format-library/index.js 7.72 kB 0 B
build/format-library/style-rtl.css 502 B 0 B
build/format-library/style.css 502 B 0 B
build/hooks/index.js 2.13 kB 0 B
build/html-entities/index.js 621 B 0 B
build/i18n/index.js 3.56 kB 0 B
build/is-shallow-equal/index.js 711 B 0 B
build/keyboard-shortcuts/index.js 2.51 kB 0 B
build/keycodes/index.js 1.94 kB 0 B
build/list-reusable-blocks/index.js 3.12 kB 0 B
build/list-reusable-blocks/style-rtl.css 226 B 0 B
build/list-reusable-blocks/style.css 226 B 0 B
build/media-utils/index.js 5.29 kB 0 B
build/notices/index.js 1.79 kB 0 B
build/nux/index.js 3.4 kB 0 B
build/nux/style-rtl.css 616 B 0 B
build/nux/style.css 613 B 0 B
build/plugins/index.js 2.56 kB 0 B
build/primitives/index.js 1.5 kB 0 B
build/priority-queue/index.js 789 B 0 B
build/redux-routine/index.js 2.85 kB 0 B
build/rich-text/index.js 14.8 kB 0 B
build/server-side-render/index.js 2.67 kB 0 B
build/shortcode/index.js 1.7 kB 0 B
build/token-list/index.js 1.28 kB 0 B
build/url/index.js 4.02 kB 0 B
build/viewport/index.js 1.85 kB 0 B
build/warning/index.js 1.14 kB 0 B
build/wordcount/index.js 1.17 kB 0 B

compressed-size-action

@aduth aduth changed the title Packages: Add new package @wordpress/lazy-import for lazily installed packages Add new package @wordpress/lazy-import for lazily installed packages May 27, 2020
Copy link
Member

@gziolo gziolo left a comment

Choose a reason for hiding this comment

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

I reviewed code and it looks great so far. I’m impressed how easy to follow it is taking into account all the inherent complexity. Major props for coming up with the idea of using npm aliases. Does it mean it also deduplicates dependencies? It should 😃

I will test benchmarks later. I think that the lock file needs verification. I expect it to cause some dependencies installed locally in the new package.

packages/lazy-import/lib/index.js Show resolved Hide resolved
packages/lazy-import/lib/index.js Outdated Show resolved Hide resolved
packages/lazy-import/lib/index.js Outdated Show resolved Hide resolved
packages/lazy-import/package.json Show resolved Hide resolved
);
}

const localModule = getLocalModuleName( arg );
Copy link
Member

Choose a reason for hiding this comment

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

If I follow it correctly, there is a tiny probability that you could install the same version of the package several times using different aliases when you use the following syntaxes:

  • my-package
  • [email protected]
  • my-package@^1.0.0
  • my-package@~1.0.0.
    and they all resolve to the same version.

I don't see it as a blocker for v1, but I was curious if you share my concern.

Copy link
Member Author

Choose a reason for hiding this comment

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

If I follow it correctly, there is a tiny probability that you could install the same version of the package several times using different aliases when you use the following syntaxes:

Yeah, it's a good point. Earlier in my iterations, I had wondered if there might be a way to avoid the hashing altogether by naming the folder based on whichever version the specifier resolves to. It would be nice, but the problem is that it's not clear up-front (at the time of calling npm install) what that version would be, except by triggering an extra network request to fetch the version ahead of the install.

There's certainly some prior art from bin/check-latest-npm.js we could use to go this route, and it may actually be an advisable path.

One of the other things I wasn't quite as fond of is the fact that you can't tell based on the folder even what the name of the package is, let alone the version. It's only necessary because I wasn't certain if all possible characters in this argument (version specifier) are valid for folder names. But if we can know the package name and specific SemVer version, we could construct folders like:

node_modules/@wordpress/lazy-import.my-package.1.0.0/package.json

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 don't love the current implementation, but I'm having a hard time thinking if there's a good alternative.

I considered:

  1. Fetch from the NPM registry beforehand? Downsides are: Extra (redundant) network request, not clear how to fetch from NPM based on a version specifier (vs. a specific version like in Framework: Add package-lock precommit check for latest NPM #21306).
  2. Install and then rename the directory once complete? Downsides are: There's no easy way to check for the local temporary install to avoid npm install on subsequent runs, since the specific desired version can't be known. It might technically be possible to find all locally-installed versions and run them against the version specifier using semver package.

Copy link
Member

@gziolo gziolo May 31, 2020

Choose a reason for hiding this comment

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

I agree that using an additional network request is concerning here. Well, if you use consistently one way to lazy import it won’t be an issue. I have just realized that the same issue happens when using Lerna’s monorepo setup and you pick different versions 😅Npm might deduplicate those dependencies but it often installs them multiple times...

@gziolo
Copy link
Member

gziolo commented May 28, 2020

I removed node_modules, installed npm modules and this is what I see on the first run:

$ rm -rf node_modules
$ npm install
$ node packages/is-shallow-equal/benchmark/index.js
(node:88834) UnhandledPromiseRejectionWarning: Error: Cannot find module '@wordpress/lazy-import.5ab856344c555e50779f56b4cd1f2e02'
Require stack:
- /Users/gziolo/Projects/gutenberg/packages/lazy-import/lib/index.js
- /Users/gziolo/Projects/gutenberg/packages/is-shallow-equal/benchmark/index.js
    at Function.Module._resolveFilename (internal/modules/cjs/loader.js:982:15)
    at Function.Module._load (internal/modules/cjs/loader.js:864:27)
    at Module.require (internal/modules/cjs/loader.js:1044:19)
    at require (internal/modules/cjs/helpers.js:77:18)
    at lazyImport (/Users/gziolo/Projects/gutenberg/packages/lazy-import/lib/index.js:111:9)
    at processTicksAndRejections (internal/process/task_queues.js:97:5)
    at async Promise.all (index 1)
(node:88834) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). To terminate the node process on unhandled promise rejection, use the CLI flag `--unhandled-rejections=strict` (see https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). (rejection id: 2)
(node:88834) [DEP0018] DeprecationWarning: Unhandled promise rejections

It works properly on the second try. I'm sure we discussed a similar issue in the context of @wordpress/create-block and external templates installed from npm. There is some sort of issue with npm cache once you call require that fails at:

at lazyImport (/Users/gziolo/Projects/gutenberg/packages/lazy-import/lib/index.js:111:9)

@aduth
Copy link
Member Author

aduth commented May 28, 2020

It works properly on the second try. I'm sure we discussed a similar issue in the context of @wordpress/create-block and external templates installed from npm. There is some sort of issue with npm cache once you call require that fails at:

Interesting that it had worked for me, then. But as you mention, it's something where there's most certainly going to be some issue with the require cache if we don't clear it between attempts. I'll make the change to see if it helps 👍

@aduth aduth force-pushed the try/lazy-import branch from e66d6da to c1f6a66 Compare May 28, 2020 20:07
@aduth
Copy link
Member Author

aduth commented May 28, 2020

I rebased the branch, since the recent Prettier upgrade did have an effect on how the code here was formatted (specifically the function chaining in the md5 utility function.

@aduth
Copy link
Member Author

aduth commented May 28, 2020

It works properly on the second try. I'm sure we discussed a similar issue in the context of @wordpress/create-block and external templates installed from npm. There is some sort of issue with npm cache once you call require that fails at:

at lazyImport (/Users/gziolo/Projects/gutenberg/packages/lazy-import/lib/index.js:111:9)

I added a cached deletion in 7b63dbc . Can you try it again?

@gziolo
Copy link
Member

gziolo commented May 29, 2020

I added 86611ce that cleans up package-lock.json to avoid having node_modules folder in the new package's folder in Gutenberg. I hope it solves the issue with npm cache. It didn't.

It works properly on the second try. I'm sure we discussed a similar issue in the context of @wordpress/create-block and external templates installed from npm. There is some sort of issue with npm cache once you call require that fails at:

at lazyImport (/Users/gziolo/Projects/gutenberg/packages/lazy-import/lib/index.js:111:9)

I added a cached deletion in 7b63dbc . Can you try it again?

The issue still applies. I don't think you can't use require.resolve here as it doesn't exist in the cache when it's called. It errors at this line now. I tried using @wordpress/lazy as a base for the check with string replace called after, but it also fails. It might be my local environment.

@gziolo
Copy link
Member

gziolo commented May 29, 2020

Hmm, there is this issue reported on both Travis and when I locally run:

$ npx wp-scripts check-licenses --dev
 WARNING  Unable to locate path for missing peer dep vue@^2.6.10. 
 WARNING  Unable to locate path for missing peer dep puppeteer@^1.10.0 || ^2.0.0. 
 ERROR  Unable to locate path for undefined@undefined. 
 WARNING  Unable to locate path for missing peer dep enzyme@^2.7.1. % 

@aduth
Copy link
Member Author

aduth commented May 29, 2020

I added 86611ce that cleans up package-lock.json to avoid having node_modules folder in the new package's folder in Gutenberg. I hope it solves the issue with npm cache. It didn't.

Can you explain how you made these changes? I'm a bit confused by the removal of npm-package-arg. There's no no reference to this dependency anywhere in the lockfile for how it's required by @wordpress/lazy-import, which seems unexpected to me.

@gziolo
Copy link
Member

gziolo commented May 29, 2020

I added 86611ce that cleans up package-lock.json to avoid having node_modules folder in the new package's folder in Gutenberg. I hope it solves the issue with npm cache. It didn't.

Can you explain how you made these changes? I'm a bit confused by the removal of npm-package-arg. There's no no reference to this dependency anywhere in the lockfile for how it's required by @wordpress/lazy-import, which seems unexpected to me.

I'm surprised as well :) I temporarily added npm-package-args in the root package.json and executed npm install and it helped. See 7362dd6.

Copy link
Member

@gziolo gziolo left a comment

Choose a reason for hiding this comment

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

I spent tons of time trying to find out why one of the packages fails to work on the first run and errors the benchmark script listed for testing purposes. I’ll continue investigation but I wouldn’t call it a blocker if you can’t replicate it. It works on subsequent tries or when I remove it from the file

lazyImport( 'shallow-equals@^1.0.0' ),
new Promise( async ( resolve ) => {
try {
await lazyImport( 'fbjs@^1.0.0' );
Copy link
Member

@gziolo gziolo May 31, 2020

Choose a reason for hiding this comment

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

For future: it would be great to support natively packages that don’t have an entry point or custom path from the package.

Copy link
Member Author

Choose a reason for hiding this comment

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

For future: it would be great to support natively packages that don’t have an entry point or custom path from the package.

Yeah, the thought had come to mind, especially since the previous implementation was able to easily import from a nested path, whether it's something that could be supported in this implementation as well.

I'll give it a quick check to see if it might be straight-forward to add support. Otherwise, I can create an issue to follow up.

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'll give it a quick check to see if it might be straight-forward to add support. Otherwise, I can create an issue to follow up.

npm-package-arg doesn't seem to handle the path very well on its own. It might be something where we can either implement the directory parsing or the entire module / version spec parsing as something custom, but I'd suggest it be done separately.

Copy link
Member Author

Choose a reason for hiding this comment

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

Follow-up task: #22869

Copy link
Member

Choose a reason for hiding this comment

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

Thanks for opening a follow up issue 💯

}

// Delete cache from prior `require` attempt.
delete require.cache[ require.resolve( localModule ) ];
Copy link
Member

Choose a reason for hiding this comment

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

In my testing, this line doesn't work when something goes wrong. We can remove it. If the package is properly installed then require in the next line works regardless.

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 puzzled about this, since it seems like given the intention of require.cache to bypass a second lookup would conflict with the intention of the following line to try the import once more.

But I guess it depends when a module is cached. Notably, the use-case here can assume that the previous attempt would have been a failure.

Briefly testing this in the terminal, it does appear that a module will not be cached if it couldn't be resolved:

⇒ node -e "try { require( './abc.js' ); } catch {} console.log( require.cache );"
[Object: null prototype] {}

So, as you mention, I suppose it should be safe to remove.

@aduth aduth merged commit 6fd5b93 into master Jun 3, 2020
@aduth aduth deleted the try/lazy-import branch June 3, 2020 20:38
@github-actions github-actions bot added this to the Gutenberg 8.3 milestone Jun 3, 2020
@gziolo
Copy link
Member

gziolo commented Jun 4, 2020

It feels so great to see it merged, let’s see how it works in action 🎉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
npm Packages Related to npm packages [Type] New API New API to be used by plugin developers or package users. [Type] Performance Related to performance efforts
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants