Skip to content

feat: install an npm package only in the mode it declares - #25623

Closed
totally-not-ai[bot] wants to merge 3 commits into
mainfrom
feat/install-npm-packages-by-mode
Closed

totally-not-ai[bot] wants to merge 3 commits into
mainfrom
feat/install-npm-packages-by-mode

Conversation

@totally-not-ai

@totally-not-ai totally-not-ai Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Summary

A versions file entry can say "mode": "lit" or "mode": "react". Until now that only decided which version was pinned — the package was still added to the application's package.json in the other mode. Now the mode decides both, so a package meant for one mode is no longer installed in the other.

What changed

  • VersionsJsonConverter now excludes a package whose declared mode is not the mode being built, the same way an exclusions array does. This is what the javadoc of getExclusions() already described, and what the excludeWebComponents build already did for React packages.
  • Packages left out because of their mode are tracked separately, in a new getModeExclusions(). They are not mixed into what a file excludes outright.
  • PinnedNpmVersions.getExclusions() reads all versions files first and excludes a mode-based package only when no file installs it in the mode being built. One file's mode must not decide for another file — the same rule the pinned versions already follow. Without this, a package another file (an add-on or an application) installs and pins was dropped from package.json and its declared version was lost.

Results: React components no longer have to list every web component package they bring, and excluding web components no longer depends on a versions file enumerating them. A package that declares no mode is installed in every mode, as before, and exclusions arrays keep working.

Use case

An add-on ships a Lit-only web component plus a React wrapper that already bundles that web component. The author wants the plain web component installed only in Lit applications, and does not want to maintain an exclusions list in the React entry.

They just declare the mode in their versions file:

{
  "core": {
    "my-field": {
      "npmName": "@acme/my-field",
      "jsVersion": "2.1.0",
      "mode": "lit"
    }
  },
  "react": {
    "my-field-react": {
      "npmName": "@acme/my-field-react",
      "jsVersion": "2.1.0",
      "mode": "react"
    }
  }
}

In a React application, @acme/my-field is now left out of package.json and @acme/my-field-react is installed. In a Lit application it is the other way round. No exclusions array needed.

Test summary

# Status What the test verifies Why it matters
1 A package declaring a mode is excluded in the other mode; a package with no mode is installed in every mode The core rule of the change; a wrong mode check would drop or add the wrong packages
2 Mode-based exclusions are not reported by getExclusions() or getDeclaredExclusions() on a single file Mixing them in would let one file's mode decide for all files
3 Across several files, a package another file installs in the built mode is not excluded Otherwise the version an add-on or application pins is silently discarded
4 A package that no file installs in the built mode is excluded Without this the feature does nothing
5 In a React build, a mode: lit package is absent from both dependencies and vaadin.dependencies of package.json End-to-end proof the exclusion reaches the written package.json
6 excludeWebComponents leaves web components out even when the versions file has no exclusions array The main practical goal: stop depending on hand-written exclusion lists
  • VersionsJsonConverterTest.modeExcludesThePackagesInstalledInTheOtherModeOnly — 1, 2
  • VersionsJsonConverterTest.declaredExclusionsLeaveOutWhatTheModeExcludes — 2
  • PinnedNpmVersionsTest.packageLeftOutOfOneFileByItsMode_isNotExcludedWhenAnotherFileInstallsIt — 3
  • PinnedNpmVersionsTest.packageNoFileInstallsInTheMode_isExcluded — 4
  • TaskUpdatePackagesNpmTest.reactEnabled_scannerDependencies_litOnlyDependenciesNotAdded — 5 (renamed and flipped, it previously asserted the old behaviour)
  • TaskUpdatePackagesNpmTest.webComponentsExcluded_reactEnabled_noExclusionsInVersions — 6
  • TaskUpdatePackagesNpmTest.webComponentsExcluded_reactDisabled_noExclusionsInVersions — 6

Deliberately not covered by new tests: the existing exclusions array behaviour, which is already pinned by unchanged tests in the same classes, and the getModeExclusions() getter itself, which is only checked through the behaviour above.

A versions file entry declaring `mode: lit` or `mode: react` says which
mode installs the package. Only the pinning followed that mode though,
while the package stayed a dependency of the application in the other
mode, unless a versions file listed it in the `exclusions` array of the
package installing it there.

The mode now decides both: a package declared for a mode other than the
one being built is excluded, the way the `exclusions` array did. This is
what the javadoc of `getExclusions()` already describes, and what the
`excludeWebComponents` build already did for the React packages.

So the React components no longer have to enumerate every web component
package they bring, and excluding web components no longer depends on a
versions file listing them. A package that declares no mode is installed
in every mode, as before, and the `exclusions` arrays keep working.
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Test Results

 1 440 files  ±0   1 524 suites  ±0   1h 33m 35s ⏱️ -4s
12 039 tests +4  11 971 ✅ +4  68 💤 ±0  0 ❌ ±0 
12 357 runs  +4  12 289 ✅ +4  68 💤 ±0  0 ❌ ±0 

Results for commit 637e66a. ± Comparison against base commit 7cc33c0.

♻️ This comment has been updated with latest results.

// React components bring the web components of a Lit package.
// Only a package declaring a mode gets here, as one without a mode
// is included in every mode.
exclusions.add(npmName);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

One issue here is that now we would drop aura as it is "mode": "lit" instead of "mode": "all".

Also any add-on that declares a mode that is not empty or all will be excluded.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Both are real, thanks.

@vaadin/aura: 0c61cb8 makes a mode-based exclusion apply only where no versions file installs the package in the mode being built. The Aura theme module of flow-components 25.4 declares @vaadin/aura without a mode, so that entry keeps it installed with React and the mode: lit of the platform entry no longer drops it. The platform entry should still become all, so the platform file is right on its own: @vaadin/aura is not a dependency of @vaadin/react-components or @vaadin/react-components-pro, so nothing else brings it in. The same check says the other mode: lit packages that the React components do not exclude today — @vaadin/a11y-base, @vaadin/field-base, @vaadin/vaadin-lumo-styles and @vaadin/vaadin-themable-mixin — are dependencies of the React components, so they are installed either way.

The add-on case is not solved by that, and I would rather not pretend it is. mode cannot tell "installed in Lit mode only" from "pinned for Lit but needed in both", so an add-on declaring mode: lit for a package that nothing else provides loses it in a React build. The versions folder is public API since 25.3, so that is a behaviour change for add-ons that already ship such a file.

The alternative keeps the exclusions explicit and removes only the part that is maintained by hand: flow-components generates, for each of its modules, a @vaadin/react-components (or -pro) entry whose exclusions are that module's own packages. The union over the files on the classpath is the list the platform keeps today, it needs no change in Flow, and add-on files keep working unchanged. It costs one extra entry per module in the generated files.

Which would you rather have? I am happy to close this in favour of the generated exclusions.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is the solution to remove mode from aura and other common deps? Why do they have "mode: lit" today?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

They should have mode: all or no mode at all. Why they are set to lit I do not know.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

For @vaadin/aura yes, for the other common dependencies no — and the history answers the "why".

mode was added to versions.json in vaadin/platform#5076, and that commit states the invariant it was added under: it marks the core and pro web components for the Lit mode, and "All dependencies in lit mode are already included in react-components dependency". So mode: lit has meant "the React components bring this instead" from the start. This PR only makes it decide what is installed on top of what is pinned.

The Aura entry is the one that does not hold that invariant. It was added later, in vaadin/platform#8042 for V25, with the same shape as the component entries around it, and @vaadin/react-components does not depend on @vaadin/aura, so nothing brings the theme to a React application.

I checked every mode: lit entry against the dependency tree of the published @vaadin/react-components and @vaadin/react-components-pro: all of them are in it, including @vaadin/a11y-base, @vaadin/field-base, @vaadin/vaadin-lumo-styles, @vaadin/vaadin-themable-mixin, @vaadin/component-base and @vaadin/overlay. Those keep the mode. @vaadin/aura is the only entry that changes: vaadin/platform#9410 removes its mode.

Nothing changes in this pull request for it, the fix is the entry in versions.json.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed for @vaadin/aura, and it is now vaadin/platform#9410, which leaves the mode out of that entry.

It was set to lit because the entry was added for V25 in vaadin/platform#8042 with the same shape as the component entries next to it. mode itself came from vaadin/platform#5076, which added it under the invariant that every package marked for the Lit mode is already a dependency of @vaadin/react-components — and the Aura theme is not.

The other common dependencies do hold that invariant. @vaadin/a11y-base, @vaadin/field-base, @vaadin/vaadin-lumo-styles and @vaadin/vaadin-themable-mixin are all in the dependency tree of the published React components, so a React application gets them either way and mode: lit stays right for them.

// React components bring the web components of a Lit package.
// Only a package declaring a mode gets here, as one without a mode
// is included in every mode.
exclusions.add(npmName);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

mode-based exclusion leaks across versions files, contradicting the design this PR preserves for pinning

getExclusions() (PinnedNpmVersions.java:336-342) unions the per-file sets, so mode-based names become global. E.g. platform declares @vaadin/grid as mode: react, an add-on's file declares it unqualified, build is Lit: pinning still works, but ExclusionFilter drops grid from scanned deps, so the @NpmPackage version the add-on declared is silently discarded — defeating the add-on downgrade path guarded at TaskUpdatePackages.java:650. If nothing pins it in the active mode, it vanishes from package.json.
Mode-based exclusion should probably be file-scoped like the pinning is.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 0c61cb8 — you are right, and the rule was already spelled out for pinning right above it.

The converter now keeps the names it leaves out because of the mode in getModeExclusions(), separate from getExclusions(), which stays what the file excludes outright. PinnedNpmVersions.getExclusions() then removes from the mode set every package that any versions file installs in the mode being built, so the mode of one file no longer decides for the others.

Your @vaadin/grid case is now a test, packageLeftOutOfOneFileByItsMode_isNotExcludedWhenAnotherFileInstallsIt, next to the existing packageLeftOutOfOneFileByItsMode_isPinnedByTheFileDeclaringIt that covers the same rule for pinning. packageNoFileInstallsInTheMode_isExcluded covers the case that should still be excluded.

Leaving a package out because of the mode it is installed in was added
to the exclusions of the versions file it came from, and getExclusions()
unions those sets, so the mode of one file decided for all of them.

A package another file installs in the mode being built was dropped from
package.json even though its version was still pinned, which discards
the version an add-on or an application declares for it.

The mode exclusions are now kept apart from what a file excludes
outright, and only the ones no file installs in this mode are excluded.
This is the rule the pinned versions already follow: the mode of one
file says nothing about what another one declares.
@@ -177,14 +193,18 @@ private void addDependency(JsonNode obj) {
return;
}
if (!isIncludedByMode(mode)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

isIncludedByMode will always return false for non-blank values that are not all/lit/react, so any typo will remove a package in both modes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, fixed in 637e66a.

Only lit and react leave a package out of the other mode now. isIncludedByMode returns true for anything else, so a value that is not a mode is treated the same as no mode at all and the package is installed in every mode instead of neither. The value is warned about, naming the package, so the typo is visible rather than silent.

This also covers a build that excludes web components: a package whose mode is not one of the modes is not identifiable as a web component package, so it is kept, as a package without a mode already was.

modeThatIsNotAMode_thePackageIsInstalledInEveryMode covers it for both modes, using "Lit " as the value.

Only `lit` and `react` leave a package out of the other mode. Any other
non-empty value was read as a mode of its own, which matched neither
mode, so a typo took the package out of both of them and removed it from
package.json altogether.

Such a value says nothing about when the package is used, so the package
is now installed in every mode, as one declaring no mode is, and the
value is warned about.
@sonarqubecloud

Copy link
Copy Markdown

@totally-not-ai

Copy link
Copy Markdown
Contributor Author

To settle the question this thread raised: the mode is the right signal, and I withdraw the suggestion of closing this in favour of generated exclusions arrays.

mode: lit means a React application does not install the package separately, because the React components already depend on it, while its version stays locked. Both halves are already in place: Flow writes the npm overrides from every entry whichever mode it declares (PinnedNpmVersions.getAllDependencies() through TaskUpdatePackages.collectPinnedNpmVersions()), and the exclusions only apply to the dependencies of package.json. This pull request makes the mode carry the second half too, instead of an exclusions array repeating it.

On the two cases raised in review:

  • @vaadin/aura carries the mode without being a dependency of the React components, which is a mistake in the entry rather than in the mode. fix: install the Aura theme in every mode platform#9410 removes it.
  • An add-on marking a package mode: lit is saying a React application does not install it. If the add-on needs it in both modes, the entry declares no mode or all, which is also what a value that is not a mode now falls back to.

The four packages marked for the Lit mode that the React components do not exclude today — @vaadin/a11y-base, @vaadin/field-base, @vaadin/vaadin-lumo-styles and @vaadin/vaadin-themable-mixin — stop being installed separately with this change. The React components depend on all four, so applications still get them at the locked version.

With the mode acting on it, the arrays are redundant: vaadin/platform#9413 removes both of them, 67 package names, and is marked as depending on this pull request so it does not land first. Every name they listed is a mode: lit entry, so nothing loses its exclusion.

@Artur-

Artur- commented Sep 14, 2026

Copy link
Copy Markdown
Member

Let's get back to this, if it is still needed and in a more compatible way

@Artur- Artur- closed this Sep 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants