Skip to content

RFC: package manifest extensions#889

Open
manzoorwanijk wants to merge 2 commits into
npm:mainfrom
manzoorwanijk:rfc-package-manifest-extensions
Open

RFC: package manifest extensions#889
manzoorwanijk wants to merge 2 commits into
npm:mainfrom
manzoorwanijk:rfc-package-manifest-extensions

Conversation

@manzoorwanijk
Copy link
Copy Markdown
Contributor

@manzoorwanijk manzoorwanijk commented Jun 2, 2026

Summary

Adds an RFC for root-owned packageExtensions, a declarative v1 package metadata repair mechanism for npm installs.

The proposal lets a project add or correct third-party package manifest fields that affect dependency graph construction before Arborist finalizes the ideal tree:

  • dependencies
  • optionalDependencies
  • peerDependencies
  • peerDependenciesMeta

Motivation

install-strategy=linked makes dependency boundaries stricter by avoiding accidental hoisting. That is useful for correctness, but it also exposes packages that import dependencies or type packages they did not declare. Today users often work around those issues by relying on hoisting, adding root dependencies, patching packages after extraction, or maintaining forks. Those options either do not work under linked installs or operate at the wrong phase of installation.

RFC 0042: Isolated mode anticipated this exact problem space:

We may want to later add a feature to npm which allows users to locally declare dependencies on behalf of packages as a stop-gap, if existing solutions to this are not enough.

It also described the older workaround:

If a package is missing a dependency, it can be temporarily fixed [...] by declaring this missing dependency as top level dependency of the repository.

That workaround is not enough for install-strategy=linked, because a root dependency does not become visible inside the isolated dependency boundary of the package that actually imports it.

This RFC proposes a root-only, deterministic way to record small third-party manifest repairs while upstream packages catch up. It intentionally scopes v1 to declarative metadata repairs rather than arbitrary install-time manifest hooks.

Why declarative v1

An imperative hook model, similar to pnpm's .pnpmfile.mjs, could solve a broader class of manifest transformation problems. This RFC proposes the declarative subset first because the linked-install migration cases are mostly small dependency metadata repairs, and a declarative model is easier to validate, lock, audit, explain, and remove once upstream packages are fixed.

Notable semantics

  • Only the root project owns packageExtensions.
  • Workspace package manifests are not extension targets; matching workspace packages warn and are ignored.
  • Selectors match package manifest name and version, not install path.
  • Multiple selectors matching the same package fail rather than merge in order-dependent ways.
  • Extensions are applied to per-ideal-tree manifest metadata copies, not shared pacote or registry cache objects.
  • npm ci validates the canonical extension hash, selector conflicts, and minimal lockfile provenance before trusting locked effective metadata.
  • The installed dependency package.json is not rewritten.
  • Dependency deletion is out of scope for v1.

Prior art

The RFC compares this proposal with pnpm packageExtensions, pnpm .pnpmfile.mjs hooks, Yarn packageExtensions, @yarnpkg/extensions, npm overrides, npm isolated mode, native dependency patching, and install-script policy RFCs.

Tests

Not run. This is a docs-only RFC proposal.

@manzoorwanijk manzoorwanijk requested review from a team as code owners June 2, 2026 14:40
@manzoorwanijk
Copy link
Copy Markdown
Contributor Author

@owlstronaut what does the team think about this?

This one is essential for isolated mode to work with packages with phantom dependencies. We are already seeing blockers for our migration to isolated mode in Gutenberg.

I will be more than happy to draft a PR to the CLI repo.

Copy link
Copy Markdown
Contributor

@owlstronaut owlstronaut left a comment

Choose a reason for hiding this comment

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

I like the shape of this. A couple comments.


1. **Lockfile placement and versioning.** Should the canonical root extension hash live on `packages[""]` or in a top-level lockfile section? Does this require a lockfile version bump, or is an additive field enough?

2. **Overwrite semantics.** This RFC allows extension values to replace existing dependency and peer ranges. Should v1 instead be add-only, with range correction left entirely to `overrides`?
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.

I think add-only for normal deps, but allow replace for peer deps. Overrides should work for normal deps "change a version", but we need the thing to allow fixing a wrong peer version.

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. That split makes the boundary with overrides much cleaner. I will update the merge semantics so dependencies and optionalDependencies are add-only in v1, while peerDependencies can still replace an existing peer range. That leaves normal dependency version changes to overrides, and keeps packageExtensions focused on adding missing normal edges plus repairing peer contracts.

}
```

The canonical hash input is the normalized root `packageExtensions` object from `package.json`. The normalized form should be key-order independent and should ignore insignificant JSON formatting. The root manifest remains authoritative for the extension rules; the lockfile hash proves that the locked graph was generated from the same canonical rule set. The install should reject multiple selectors that match the same candidate package before writing lockfile state. Package entries continue to store their normal effective `dependencies`, `optionalDependencies`, `peerDependencies`, and `peerDependenciesMeta` fields after extension application.
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.

the rules for the hash should probably be bulleted and nailed down tight. We wouldn't want drift on this

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.

Thanks.
I will turn the hash paragraph into explicit canonicalization requirements.

@manzoorwanijk
Copy link
Copy Markdown
Contributor Author

I like the shape of this. A couple comments.

Thank you for the review @owlstronaut.

On a side note, I would also like to ask a broader design question before going too far down the declarative path: would the npm team be open to adopting an imperative, root-owned manifest hook API, similar to pnpm's .pnpmfile.mjs / .pnpmfile.cjs hooks.readPackage(pkg, context), either instead of or as a later complement to packageExtensions?

The current RFC intentionally proposes a declarative v1 because it is easier to validate, lock, audit, explain, and keep deterministic under npm ci.

An imperative .npmfile.mjs-style API could cover broader transformations, such as deleting dependency entries, conditional metadata repairs, or changes outside the dependency graph, but it would also introduce arbitrary install-time code, lockfile representation questions, reproducibility concerns, and a larger policy surface.

Would maintainers prefer that this RFC stay focused on declarative packageExtensions, or would it be useful to draft a companion RFC for .npmfile.mjs / .npmfile.cjs hooks so the team can compare the two designs directly?

@manzoorwanijk
Copy link
Copy Markdown
Contributor Author

manzoorwanijk commented Jun 5, 2026

Here is the implementation PR npm/cli#9496

The reason for the above question about imperative API, is that the list in the package.json can get too long.

For example, while testing the implementation in Gutenberg, here is what the list looked like 😄

packageExtensions
"packageExtensions": {
	"@ariakit/react-core": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"@ariakit/test": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"@dnd-kit/accessibility": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"@dnd-kit/core": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"@dnd-kit/sortable": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"@dnd-kit/utilities": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"@emotion/primitives-core": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"@emotion/use-insertion-effect-with-fallbacks": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"@floating-ui/react-dom": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"@tanstack/react-router": {
		"peerDependencies": {
			"@types/react": "*",
			"@types/react-dom": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			},
			"@types/react-dom": {
				"optional": true
			}
		}
	},
	"@use-gesture/react": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"cmdk": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"framer-motion": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"re-resizable": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"react-autosize-textarea": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"react-colorful": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"react-day-picker": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"react-easy-crop": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"react-freeze": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"reselect": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"use-latest-callback": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	}
}

With an imperative API, this could be simplified to a list of dependencies, looped over to fix.

@manzoorwanijk manzoorwanijk force-pushed the rfc-package-manifest-extensions branch from 4e4daf7 to 463cd08 Compare June 5, 2026 14:34
@owlstronaut
Copy link
Copy Markdown
Contributor

owlstronaut commented Jun 5, 2026

@manzoorwanijk for a package the size of Gutenberg only having that size of a list, it makes me like the declarative even more. The package.json is ugly, but that's among the worst it'll get and is a lot safer. We can always revisit

@manzoorwanijk
Copy link
Copy Markdown
Contributor Author

@manzoorwanijk for a package the size of Gutenberg only having that size of a list, it makes me like the declarative even more. The package.json is ugly, but that's among the worst it'll get and is a lot safer. We can always revisit

True. We can revisit this later.

Some benefits of an imperative API are that you can:

  • Explain the reason in comments
  • Link to open issues on target repos
  • Make conditional changes
  • Read the existing dependency versions etc.

@ljharb
Copy link
Copy Markdown
Contributor

ljharb commented Jun 5, 2026

If this is going to go in package.json, then it probably shouldn't work in packages that don't have private:true, since it'll greatly inflate the size of the packument.

@manzoorwanijk
Copy link
Copy Markdown
Contributor Author

If this is going to go in package.json, then it probably shouldn't work in packages that don't have private:true, since it'll greatly inflate the size of the packument.

Good point. Since packageExtensions is root-only and ignored when it appears in dependencies, publishing it does not make sense.

There is precedent from the native dependency patching work: patchedDependencies is root-only, so it is stripped from the published manifest and from the packed tarball's package.json in npm/pacote#497.

Would it be acceptable for this RFC to require the same publishing behavior for packageExtensions, either by failing npm publish for non-private packages that contain the field or by stripping the field during pack/publish?

That would let public packages use packageExtensions locally for their own CI and isolated-mode migrations, while preventing the field from inflating registry metadata or appearing in consumers' packuments.

@ljharb
Copy link
Copy Markdown
Contributor

ljharb commented Jun 6, 2026

Failing seems better, since it more firmly establishes that it's only for apps.

If we want packages to use it, I think it needs to go somewhere other than package.json.

@manzoorwanijk
Copy link
Copy Markdown
Contributor Author

Failing seems better, since it more firmly establishes that it's only for apps.

If we want packages to use it, I think it needs to go somewhere other than package.json.

One concern with failing npm publish for non-private packages that contain packageExtensions: what should happen for a solo public package repo that uses packageExtensions only for its own local install, CI, or isolated-mode migration?

For example, a public library may have a single root package.json, publish that package to npm, and still need packageExtensions locally to repair third-party dependency metadata during its own build or tests.

Should this RFC intentionally make that unsupported because packageExtensions is application-only policy, or should npm allow that use case by keeping packageExtensions out of published artifacts instead of failing publish?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants