How to publish a JS package that gracefully fails if an optional peer dependency is not installed

Viewed 88

Is there a way to design a JavaScript package for npm that can dynamically import() a third-party npm package, but fail gracefully if that package is not installed as a dependency in the consuming application?

What I want to do is create a React component library that has drag-and-drop capability using react-dnd, but only requires react-dnd as a dependency if the application utilizes that feature. The package.json setup would be something like this:

{
  "name": "my-react-component-lib",
  "devDependencies": {
    "react": "18.2.0",
    "react-dnd": "^16.0.1",
    "react-dnd-html5-backend": "^16.0.1",
    "react-dom": "18.2.0"
  },
  "peerDependecies": {
    "react": ">=16.8.0",
    "react-dnd": "^14.0.0",
    "react-dnd-html5-backend": "^14.0.0"
  },
  "peerDependeciesMeta": {
    "react-dnd": {
      "optional": true
    },
    "react-dnd-html5-backend": {
      "optional": true
    }
  }
}

The main component code switches from a basic, non-DnD component tree to a DnD-enabled component tree (using Hooks from react-dnd) based on a prop the user provides. Something like this:

import { MainComponentBase } from "./MainComponentBase";
import { MainComponentWithDndProvider } from "./MainComponentDnD";
import type { MainComponentProps } from "./types";

export const MainComponent = (props: MainComponentProps) => {
  const key = props.enableDragAndDrop ? "dnd" : "no-dnd";

  if (props.enableDragAndDrop) {
    return <MainComponentWithDndProvider key={key} {...props} />;
  }

  return <MainComponentBase key={key} {...props} />;
};

MainComponentWithDndProvider runs a custom Hook with a run-only-once useEffect that dynamically imports react-dnd and sets a state variable containing the useDrag and useDrop Hooks from that import. Those Hook functions then get passed down to some recursive sub-components that each use them (the non-DnD sub-components do not call them, hence the key prop to make sure we avoid calling a different number of Hooks between renders).

Here is the custom Hook, called useReactDnD:

export type UseReactDnD = typeof import('react-dnd') &
  Pick<typeof import('react-dnd-html5-backend'), 'HTML5Backend'>;

export const useReactDnD = (dndParam?: UseReactDnD) => {
  const [dnd, setDnd] = useState<UseReactDnD | null>(dndParam ?? null);

  useEffect(() => {
    let didCancel = false;

    const getDnD = async () => {
      const [reactDnD, reactDnDHTML5Be] = await Promise.all([
        import('react-dnd').catch(() => null),
        import('react-dnd-html5-backend').catch(() => null),
      ]);

      if (!didCancel) {
        if (reactDnD && reactDnDHTML5Be) {
          setDnd(() => ({ ...reactDnD, HTML5Backend: reactDnDHTML5Be.HTML5Backend }));
        } else {
          // Log a warning to the console about enabling drag-and-drop
          // without installing the necessary dependencies
        }
      }
    };

    if (!dnd) {
      getDnD();
    }

    return () => {
      didCancel = true;
    };
  }, []);

  return dnd;
};

And here is the DnD-enabled component:

export const MainComponentWithDndProvider = (props: MainComponentProps) => {
  const dnd = useReactDnD(props.dnd);
  const key = dnd ? 'dnd' : 'no-dnd';

  if (!dnd) {
    return <MainComponentBase key={key} {...props} />;
  }

  const { DndProvider, HTML5Backend } = dnd;

  return (
    <DndProvider key={key} backend={HTML5Backend} debugMode={props.debugMode}>
      <MainComponentWithoutDndProvider {...{ ...props, dnd }} />
    </DndProvider>
  );
};

(MainComponentWithoutDndProvider just wraps MainComponentBase in DndContext.Consumer and sets up the sub-components.)

If the consuming application decides to utilize the drag-and-drop feature of my-react-component-lib, it can set the enableDragAndDrop prop to true and install react-dnd/react-dnd-html5-backend. If the DnD feature was not to going to be implemented, the extra dependencies would not be necessary as long as the enableDragAndDrop prop remained false or undefined.

This setup seems to work fine when the DnD dependencies are installed in the consuming application, but when they're not then I run into problems. I've only tried it with a Vite-based application, which uses esbuild for the dev server and Rollup for production builds. Both seem to have issues when dynamic imports fail.

For example, when running the app's dev server with vite, this error appears pointing to the import('react-dnd') line:

[plugin:vite:import-analysis] Failed to resolve import "react-dnd" from "node_modules\.vite\deps\my-react-component-lib.js?v=9969840a". Does the file exist?

Is there a better way to do it?

1 Answers

An alternative (not necessarily better) would be to let the user provide the library if needed:

import { optionalLib } from 'optional-lib';
import { init } from 'your-lib';
init(optionalLib);

That avoids the dynamic import and optional dependencies (which are a very rare npm feature). However it requires more knowledge from the user and makes typing the code used from the dependency much harder.

Related