fix(shadcn): preserve existing dependency specifiers in package.json (#10967)

Fixes #10525
This commit is contained in:
shadcn
2026-06-18 21:46:26 +04:00
committed by GitHub
parent c2ddedf5d2
commit 365d53b590
6 changed files with 199 additions and 3 deletions

View File

@@ -0,0 +1,5 @@
---
"shadcn": patch
---
Preserve existing dependency specifiers in package.json when running `shadcn add`.

View File

@@ -16,8 +16,19 @@ export async function updateDependencies(
silent?: boolean
}
) {
dependencies = Array.from(new Set(dependencies))
devDependencies = Array.from(new Set(devDependencies))
const packageInfo = getPackageInfo(config.resolvedPaths.cwd, false)
const packageManager = await getUpdateDependenciesPackageManager(config)
// Expo resolves its own SDK-compatible versions via `expo install`, so for
// Expo projects we still dedupe requests but must not skip already declared
// dependencies — otherwise we'd block intentional version alignment.
const skipInstalled = packageManager !== "expo"
dependencies = normalizeDependencyRequests(dependencies, packageInfo, {
skipInstalled,
})
devDependencies = normalizeDependencyRequests(devDependencies, packageInfo, {
skipInstalled,
})
if (!dependencies?.length && !devDependencies?.length) {
return
@@ -31,7 +42,6 @@ export async function updateDependencies(
const dependenciesSpinner = spinner(`Installing dependencies.`, {
silent: options.silent,
})?.start()
const packageManager = await getUpdateDependenciesPackageManager(config)
// Offer to use --force or --legacy-peer-deps if using React 19 with npm.
let flag = ""
@@ -74,6 +84,88 @@ export async function updateDependencies(
dependenciesSpinner?.succeed()
}
/**
* The registry hands us bare package names (e.g. "recharts"). Forwarding a bare
* name to `pnpm add` / `npm install` re-resolves it to the current `latest` and
* overwrites whatever specifier is already declared in package.json. Re-running
* `shadcn add` therefore silently rewrites existing dependency ranges (#10525).
*
* To keep the operation idempotent we drop bare requests for packages that are
* already declared, while still installing explicit specs (e.g. "recharts@3.8.0")
* so a registry item can intentionally pin a version. Within a single request we
* also dedupe by package name, preferring the explicit spec over a bare one.
*/
function normalizeDependencyRequests(
dependencies: RegistryItem["dependencies"] = [],
packageInfo: ReturnType<typeof getPackageInfo>,
{ skipInstalled = true }: { skipInstalled?: boolean } = {}
) {
const installedDependencies = new Set([
...Object.keys(packageInfo?.dependencies ?? {}),
...Object.keys(packageInfo?.devDependencies ?? {}),
...Object.keys(packageInfo?.optionalDependencies ?? {}),
...Object.keys(packageInfo?.peerDependencies ?? {}),
])
const installRequests = new Map<
string,
{ dependency: string; hasSpecifier: boolean }
>()
for (const dependency of dependencies) {
const packageRequest = parsePackageRequest(dependency)
// Protocol/alias specs (file:, workspace:, git+https:, npm:, etc.) are kept
// as-is since we cannot reliably reason about their declared version.
if (!packageRequest) {
installRequests.set(dependency, { dependency, hasSpecifier: true })
continue
}
// Bare name that is already declared in package.json -> skip so we never
// rewrite the existing specifier (this is the #10525 fix).
if (
skipInstalled &&
!packageRequest.hasSpecifier &&
installedDependencies.has(packageRequest.name)
) {
continue
}
const existing = installRequests.get(packageRequest.name)
if (!existing || (!existing.hasSpecifier && packageRequest.hasSpecifier)) {
installRequests.set(packageRequest.name, {
dependency,
hasSpecifier: packageRequest.hasSpecifier,
})
}
}
return Array.from(installRequests.values()).map(
({ dependency }) => dependency
)
}
function parsePackageRequest(dependency: string) {
// Protocol-prefixed specs: file:, workspace:, link:, git+https:, npm:, etc.
if (/^[a-z][a-z0-9+.-]*:/i.test(dependency)) {
return null
}
const match = dependency.startsWith("@")
? dependency.match(/^(@[^/]+\/[^@/]+)(@.+)?$/)
: dependency.match(/^([^@/]+)(@.+)?$/)
if (!match) {
return null
}
return {
name: match[1],
hasSpecifier: Boolean(match[2]),
}
}
function shouldPromptForNpmFlag(config: Config) {
const packageInfo = getPackageInfo(config.resolvedPaths.cwd, false)

View File

@@ -0,0 +1,11 @@
{
"name": "test-cli-project-expo-existing-deps",
"version": "1.0.0",
"main": "index.js",
"author": "shadcn",
"license": "MIT",
"dependencies": {
"expo": "^52.0.0",
"recharts": "^2.15.4"
}
}

View File

@@ -0,0 +1,15 @@
{
"name": "test-cli-project-pnpm-existing-deps",
"version": "1.0.0",
"main": "index.js",
"author": "shadcn",
"license": "MIT",
"dependencies": {
"@base-ui/react": "^1.4.1",
"class-variance-authority": "^0.7.1",
"recharts": "^2.15.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.1"
}
}

View File

@@ -0,0 +1 @@
lockfileVersion: '6.0'

View File

@@ -154,4 +154,76 @@ describe("updateDependencies", () => {
)
}
)
test("skips bare dependencies already declared in package.json (#10525)", async () => {
const cwd = path.resolve(
__dirname,
"../../fixtures/project-pnpm-existing-deps"
)
await updateDependencies(
// @base-ui/react, class-variance-authority and recharts are already
// declared in the fixture; only react-is and the explicit recharts@3.8.0
// spec should reach the package manager.
[
"@base-ui/react",
"class-variance-authority",
"react-is",
"recharts@3.8.0",
],
["@tailwindcss/postcss", "typescript"],
{ resolvedPaths: { cwd } } as any,
{ silent: true }
)
expect(execa).toHaveBeenCalledTimes(2)
expect(execa).toHaveBeenCalledWith(
"pnpm",
["add", "react-is", "recharts@3.8.0"],
{ cwd }
)
expect(execa).toHaveBeenCalledWith("pnpm", ["add", "-D", "typescript"], {
cwd,
})
})
test("prefers explicit specs over duplicate bare requests", async () => {
const cwd = path.resolve(__dirname, "../../fixtures/project-pnpm")
await updateDependencies(
["recharts", "recharts@3.8.0", "@base-ui/react", "@base-ui/react@1.4.1"],
[],
{ resolvedPaths: { cwd } } as any,
{ silent: true }
)
expect(execa).toHaveBeenCalledTimes(1)
expect(execa).toHaveBeenCalledWith(
"pnpm",
["add", "recharts@3.8.0", "@base-ui/react@1.4.1"],
{ cwd }
)
})
test("does not skip already declared deps for expo projects", async () => {
const cwd = path.resolve(
__dirname,
"../../fixtures/project-expo-existing-deps"
)
// recharts is already declared, but `expo install` must still see it so it
// can align the version with the installed SDK. Duplicates are still deduped.
await updateDependencies(
["recharts", "recharts", "react-is"],
[],
{ resolvedPaths: { cwd } } as any,
{ silent: true }
)
expect(execa).toHaveBeenCalledWith(
"npx",
["expo", "install", "recharts", "react-is"],
{ cwd }
)
})
})