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
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
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!