From 365d53b590a6ddb79c1487a9dbeabcde5f4ac37f Mon Sep 17 00:00:00 2001 From: shadcn Date: Thu, 18 Jun 2026 21:46:26 +0400 Subject: [PATCH] fix(shadcn): preserve existing dependency specifiers in package.json (#10967) Fixes #10525 --- .changeset/tame-spoons-grow.md | 5 + .../src/utils/updaters/update-dependencies.ts | 98 ++++++++++++++++++- .../project-expo-existing-deps/package.json | 11 +++ .../project-pnpm-existing-deps/package.json | 15 +++ .../project-pnpm-existing-deps/pnpm-lock.yaml | 1 + .../updaters/update-dependencies.test.ts | 72 ++++++++++++++ 6 files changed, 199 insertions(+), 3 deletions(-) create mode 100644 .changeset/tame-spoons-grow.md create mode 100644 packages/shadcn/test/fixtures/project-expo-existing-deps/package.json create mode 100644 packages/shadcn/test/fixtures/project-pnpm-existing-deps/package.json create mode 100644 packages/shadcn/test/fixtures/project-pnpm-existing-deps/pnpm-lock.yaml diff --git a/.changeset/tame-spoons-grow.md b/.changeset/tame-spoons-grow.md new file mode 100644 index 0000000000..762d1a87c9 --- /dev/null +++ b/.changeset/tame-spoons-grow.md @@ -0,0 +1,5 @@ +--- +"shadcn": patch +--- + +Preserve existing dependency specifiers in package.json when running `shadcn add`. diff --git a/packages/shadcn/src/utils/updaters/update-dependencies.ts b/packages/shadcn/src/utils/updaters/update-dependencies.ts index 7b0eff9150..99f46abdad 100644 --- a/packages/shadcn/src/utils/updaters/update-dependencies.ts +++ b/packages/shadcn/src/utils/updaters/update-dependencies.ts @@ -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, + { 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) diff --git a/packages/shadcn/test/fixtures/project-expo-existing-deps/package.json b/packages/shadcn/test/fixtures/project-expo-existing-deps/package.json new file mode 100644 index 0000000000..af7c0a0cce --- /dev/null +++ b/packages/shadcn/test/fixtures/project-expo-existing-deps/package.json @@ -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" + } +} diff --git a/packages/shadcn/test/fixtures/project-pnpm-existing-deps/package.json b/packages/shadcn/test/fixtures/project-pnpm-existing-deps/package.json new file mode 100644 index 0000000000..266121ec5b --- /dev/null +++ b/packages/shadcn/test/fixtures/project-pnpm-existing-deps/package.json @@ -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" + } +} diff --git a/packages/shadcn/test/fixtures/project-pnpm-existing-deps/pnpm-lock.yaml b/packages/shadcn/test/fixtures/project-pnpm-existing-deps/pnpm-lock.yaml new file mode 100644 index 0000000000..7a06cc7962 --- /dev/null +++ b/packages/shadcn/test/fixtures/project-pnpm-existing-deps/pnpm-lock.yaml @@ -0,0 +1 @@ +lockfileVersion: '6.0' diff --git a/packages/shadcn/test/utils/updaters/update-dependencies.test.ts b/packages/shadcn/test/utils/updaters/update-dependencies.test.ts index f13b576a6b..0870392864 100644 --- a/packages/shadcn/test/utils/updaters/update-dependencies.test.ts +++ b/packages/shadcn/test/utils/updaters/update-dependencies.test.ts @@ -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 } + ) + }) })