mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-07-01 00:24:20 +00:00
fix(shadcn): preserve existing dependency specifiers in package.json (#10967)
Fixes #10525
This commit is contained in:
5
.changeset/tame-spoons-grow.md
Normal file
5
.changeset/tame-spoons-grow.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"shadcn": patch
|
||||
---
|
||||
|
||||
Preserve existing dependency specifiers in package.json when running `shadcn add`.
|
||||
@@ -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)
|
||||
|
||||
|
||||
11
packages/shadcn/test/fixtures/project-expo-existing-deps/package.json
vendored
Normal file
11
packages/shadcn/test/fixtures/project-expo-existing-deps/package.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
15
packages/shadcn/test/fixtures/project-pnpm-existing-deps/package.json
vendored
Normal file
15
packages/shadcn/test/fixtures/project-pnpm-existing-deps/package.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
1
packages/shadcn/test/fixtures/project-pnpm-existing-deps/pnpm-lock.yaml
generated
vendored
Normal file
1
packages/shadcn/test/fixtures/project-pnpm-existing-deps/pnpm-lock.yaml
generated
vendored
Normal file
@@ -0,0 +1 @@
|
||||
lockfileVersion: '6.0'
|
||||
@@ -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 }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user