Angelo Verlain at

Setting up React Cosmos in Remix

Build a sandbox for developing and testing UI components in Remix.
#code ยท #tutorial



Hello friends!

As many of you might already know Remix is a framework to build websites. Much like Next.js, it's production-grade, but unlike Next, it is extremely robust, easy and considerate. If you didn't check it out already, now might be the time.

Remix ๐Ÿ’” Storybook

Storybook
Storybook

I was building an app using Remix, then as usual, I started writing some components like Buttons, Select, Forms etc. I wanted to prototype those components and view them in the browser as a way to speed up the development process using Storybook. But sadly, Storybook does not work on Remix. It's fine as long as you don't use some of Remix's provided components. This is because of the way Remix works, and that if you use Remix's features such as Link, NavLink or useTransition, you need to wrap your app in a certain Remix component. That Remix component doesn't in fact exist, and is just a combination of a React Context, Router (provided by react-router and config.) I'm sure if we try hard, we may be able to make it work on Storybook, but right now, I don't know of anyone who has figured it out yet.

Remix ๐Ÿ’ Cosmos

Cosmos
Cosmos

React Cosmos is yet another tool that aims to achieve exactly the same as Storybook. Given, it doesn't have the docs, community or polish of Storybook, but I had used it before and it worked exceptionally well. So I thought I might give it another try. I found Rasmus who already made Remix work with Cosmos. you can read his blog post as it explains what he did. I extended on his work to make it work better in my use case, and I hope it can be helpful for you too.

My approach

I based my work on Rasmus' and made some other modifications. Here is the full list of file changes.

Structure of changes:

.
โ”œโ”€โ”€ .gitignore
โ”œโ”€โ”€ cosmos.config.json
โ”œโ”€โ”€ package.json
โ”œโ”€โ”€ prod.cosmos.config.json
โ”œโ”€โ”€ app
โ”‚   โ”œโ”€โ”€ components
โ”‚   โ”‚   โ””โ”€โ”€ cosmos.decorator.tsx
โ”‚   โ””โ”€โ”€ routes
โ”‚       โ””โ”€โ”€ cosmos.tsx
โ””โ”€โ”€ scripts
    โ””โ”€โ”€ generate-cosmos-userdeps

cosmos.config.json

{
  "staticPath": "public",
  "watchDirs": ["app"],
  "userDepsFilePath": "app/cosmos.userdeps.js",
  "experimentalRendererUrl": "http://localhost:3000/cosmos"
}

This file is pretty much self explanatory. It is Cosmos's config. I don't think the staticPath is necessary, because Cosmos doesn't inherently need it, but i haven't tested it yet, so there it is. The experimentalRendererUrl must match the URL you use for Remix in development, just add the /cosmos path, for which we'll create a page route soon. The userDepsFilePath is a file that is generated by Cosmos, and it outlines all the files that it uses (decorators, fixtures, config).

prod.cosmos.config.json

{
  "staticPath": "public",
  "watchDirs": ["app"],
  "userDepsFilePath": "app/cosmos.userdeps.js",
  "experimentalRendererUrl": "https://whereveryourappishosted:3000/cosmos"
}

This is almost a perfect clone of the cosmos.config.json, but notice the changed experimentalRendererUrl that points to wherever you app is hosted for production, this is important.

app/routes/cosmos.json

import { useCallback, useState } from "react";
import { useEffect } from "react";
import type { HeadersFunction } from "@remix-run/node";
import type { LinksFunction } from "@remix-run/node";

/// @ts-ignore - is generated everytime by cosmos
import { decorators, fixtures, rendererConfig } from "~/cosmos.userdeps.js";

// only load cosmos in the browser
const shouldLoadCosmos = typeof window !== "undefined";

// CORS: allow sites hosted on other URLs to access this one.
export const headers: HeadersFunction = () => {
  return { "Access-Control-Allow-Origin": "*" };
};

// mount the DOM renderer, notice it hydrates into `body`
function Cosmos() {
  const [cosmosLoaded, setCosmosLoaded] = useState(false);
  const loadRenderer = useCallback(async () => {
    /// @ts-ignore - works
    const { mountDomRenderer } = (await import("react-cosmos/dom")).default;
    mountDomRenderer({
      decorators,
      fixtures,
      rendererConfig: {
        ...rendererConfig,
        containerQuerySelector: "body",
      },
    } as any);
  }, []);

  useEffect(() => {
    if (shouldLoadCosmos && !cosmosLoaded) {
      loadRenderer();
      setCosmosLoaded(true);
    }
  }, [loadRenderer, cosmosLoaded]);

  return <div className="cosmos-container" />;
}

export default Cosmos;

This is almost the same as Rasmus's version.

app/components/cosmos.decorator.tsx

import { CustomApp } from "~/root";
import { MemoryRouter } from "react-router-dom";
import { useEffect, useState } from "react";
import { createTransitionManager } from "@remix-run/react/dist/transition";
import { LiveReload, Scripts, ScrollRestoration } from "@remix-run/react";

const clientRoutes = [
  {
    id: "idk",
    path: "idk",
    hasLoader: true,
    element: "",
    module: "",
    action: () => null,
  },
];

let context = {
  routeModules: { idk: { default: () => null } },
  manifest: {
    routes: {
      idk: {
        hasLoader: true,
        hasAction: false,
        hasCatchBoundary: false,
        hasErrorBoundary: false,
        id: "idk",
        module: "idk",
      },
    },
    entry: { imports: [], module: "" },
    url: "",
    version: "",
  },
  matches: [],
  clientRoutes,
  routeData: {},
  appState: {} as any,
  transitionManager: createTransitionManager({
    routes: clientRoutes,
    location: {
      key: "default",
      hash: "#hello",
      pathname: "/",
      search: "?a=b",
      state: {},
    },
    loaderData: {},
    onRedirect(to, state?) {
      console.log("redirected");
    },
  }),
};

const Decorator = ({ children }: { children: any }) => {
  const [result, setResult] = useState<any | null>(null);

  useEffect(() => {
    /// @ts-expect-error i swear to God importing is allowed
    import(
      /// @ts-expect-error Node expects CommonJS, but we're giving him ESM
      "@remix-run/react/dist/esm/components"
    ).then(
      (
        { RemixEntryContext }:
          typeof import("@remix-run/react/dist/components"),
      ) => {
        setResult(
          <RemixEntryContext.Provider value={context}>
            <MemoryRouter>
              {children}
              <ScrollRestoration />
              <Scripts />
              <LiveReload />
            </MemoryRouter>
          </RemixEntryContext.Provider>,
        );
      },
    );
  }, []);

  if (!result) return <>Loading...</>;

  if (result) return result;
};
export default Decorator;

This is the file that took me most of the time to implement. One drawback is that it only renders on the client, so Cosmos won't have SSR. It basically wraps around all other components in a directory deeper than it is app/components/** in this case. It wraps the provided children around inside a MemoryRouter which allows react-router specific things to work (such as Link or NavLink) It then wraps (decorates) that around in a RemixEntryContext Provider together with a very fake context (it was partially stolen from Remix test data).

scripts/generate-cosmos-userdeps.js

const { generateUserDepsModule } = require(
  "react-cosmos/dist/userDeps/generateUserDepsModule.js",
);
const { getCosmosConfigAtPath } = require(
  "react-cosmos/dist/config/getCosmosConfigAtPath",
);
const { join, relative, dirname } = require("path");
const { writeFileSync } = require("fs");

const config = getCosmosConfigAtPath(
  join(process.cwd(), "prod.cosmos.config.json"),
);

const userdeps = generateUserDepsModule({
  cosmosConfig: config,
  rendererConfig: {},
  relativeToDir: relative(process.cwd(), dirname(config.userDepsFilePath)),
});

writeFileSync(config.userDepsFilePath, userdeps);

This file generates the userDeps that I talked about earlier. There is no direct script to generate it on the CLI, but this works.

package.json

Here are some convinience scripts to build the mix.

{
  "private": true,
  "sideEffects": false,
  "scripts": {
    "build:remix": "remix build",
    "build:userdeps": "node scripts/generate-cosmos-userdeps.js",
    "build": "yarn build:userdeps && yarn build:remix",
    "dev": "remix dev",
    "start": "remix-serve build",
    "cosmos": "cosmos",
    "build:cosmos": "cosmos-export --config prod.cosmos.config.json",
    "start:cosmos": "serve cosmos-export"
  }
}

.gitignore

/app/cosmos.userdeps.js
/cosmos-export

Thanks for being with me today. One love!



โ†‘ Scroll to Top

ยฉ Angelo Verlain 2024