How I optimised icon loading in Storybook and Vite
The Problem
For the purposes of this blog post the Storybook in question is a React based component library using Vite as the Storybook builder. It is unfortunately closed source, but I will provide some working code based on it in this blog post. The icons are each their own React components along the lines of:
// @filename: icons/LightBulb.tsx
import React from "react";
import function IconBase(props: IconProps): React.JSX.Element
IconBase, { type IconProps = {
string?: string | number | undefined;
suppressHydrationWarning?: boolean | undefined;
className?: string | undefined;
color?: string | undefined;
height?: string | number | undefined;
... 472 more ...;
key?: React.Key | ... 1 more ... | undefined;
}
IconProps } from "../IconBase";
export default function function LightBulbIcon(props: IconProps): React.JSX.Element
LightBulbIcon(props: IconProps
props: type IconProps = {
string?: string | number | undefined;
suppressHydrationWarning?: boolean | undefined;
className?: string | undefined;
color?: string | undefined;
height?: string | number | undefined;
... 472 more ...;
key?: React.Key | ... 1 more ... | undefined;
}
IconProps) {
return (
<function IconBase(props: IconProps): React.JSX.Element
IconBase>
<JSX.IntrinsicElements.path: React.SVGProps<SVGPathElement>
path React.SVGAttributes<SVGPathElement>.d?: string | undefined
d="..."></JSX.IntrinsicElements.path: React.SVGProps<SVGPathElement>
path>
</function IconBase(props: IconProps): React.JSX.Element
IconBase>
);
}
All of the 200+ icons are then re-exported from a single index file like so:
// @filename: index.tsx
export { default as LightBulbIcon } from "./icons/LightBulb";
// ...
I am aware there are better ways of doing icons in React land, this project is a little special in that React is never run on the client, but that is beside the point. The problem here is with the way Vite works in a dev environment, it does not bundle source code, it does an HTTP request for every import and runs esbuild on each file to compile away TypeScript and JSX. This means that if we use a single icon from the index file it makes 200+ HTTP requests, which as you can imagine is a lot of overhead and very slow. So I started looking for solutions.
Initial findings
The first place I looked was the Vite docs, as I find them to be a great example of documentation and very easy to read. I quickly came across “Dependency Pre-Bundling” which sounded like exactly what I need. It even speaks about the exact issue I am having “Some packages ship their ES modules builds as many separate files importing one another.”. So I started messing around with the optimizeDeps
option, trying to get it to bundle my index file. This proved difficult, I could not get it to bundle the file no matter how I formatted the paths. It then occurred to me that all the docs on this subject are talking about node_modules
or linked dependencies. So I did the next logical step and set up an alias to trick Vite into thinking it was a node_module
, this still didn’t seem to work, I can only assume Vite is very aware of what is source code and what isn’t. So I had to come up with something else.
A three-pronged approach
After getting on with some other work, I thought about how I might solve this issue in the background. I eventually settled on a three-pronged attack on icons across the board.
One: Direct imports
Components that consume icons as a one-off can simply import the raw file, not the index. This means it will only load what it needs for each of these components.
Two: Lazy-loaded modules
For stories that have a control for the currently shown icon, we will dynamically import each of the icons, so they aren’t requested until selected. This was surprisingly simple with Vite’s import.meta.glob
API and the React.lazy
API. It looks something like this:
import React from "react";
import function IconBase(props: IconProps): React.JSX.Element
IconBase from "./IconBase";
const const icons: Record<string, () => Promise<{
default: typeof IconBase;
}>>
icons = import.meta.ImportMeta.glob: ImportGlobFunction
<{
default: typeof IconBase;
}>(glob: string | string[], options?: ImportGlobOptions<false, string> | undefined) => Record<string, () => Promise<{
default: typeof IconBase;
}>> (+2 overloads)
glob<{ default: (props: IconProps) => React.JSX.Element
default: typeof function IconBase(props: IconProps): React.JSX.Element
IconBase }>("./icons/*.tsx");
export default function function useIcon(iconName: string | undefined): React.LazyExoticComponent<typeof IconBase> | undefined
useIcon(
iconName: string | undefined
iconName: string | undefined
): React.type React.LazyExoticComponent<T extends React.ComponentType<any>> = React.ExoticComponent<React.CustomComponentPropsWithRef<T>> & {
readonly _result: T;
}
LazyExoticComponent<typeof function IconBase(props: IconProps): React.JSX.Element
IconBase> | undefined {
if (iconName: string | undefined
iconName) {
const const iconPath: string
iconPath = function getIconPathFromName(iconName: string): string
getIconPathFromName(iconName: string
iconName);
const const iconModule: () => Promise<{
default: (props: IconProps) => React.JSX.Element;
}>
iconModule = const icons: Record<string, () => Promise<{
default: (props: IconProps) => React.JSX.Element;
}>>
icons[const iconPath: string
iconPath];
if (const iconModule: () => Promise<{
default: (props: IconProps) => React.JSX.Element;
}>
iconModule) {
return React.function React.lazy<(props: IconProps) => React.JSX.Element>(factory: () => Promise<{
default: (props: IconProps) => React.JSX.Element;
}>): React.LazyExoticComponent<(props: IconProps) => React.JSX.Element>
lazy(const iconModule: () => Promise<{
default: (props: IconProps) => React.JSX.Element;
}>
iconModule);
}
}
}
export const const iconNames: string[]
iconNames = var Object: ObjectConstructor
Object.ObjectConstructor.keys(o: {}): string[] (+1 overload)
keys(const icons: Record<string, () => Promise<{
default: typeof IconBase;
}>>
icons);
This gives us a hook that returns a lazy component or undefined given an icon name. We can then use it in our stories file:
// @filename: Icon.stories.tsx
import React from "react";
import function IconBase(props: IconProps): React.JSX.Element
IconBase, { type IconProps = {
string?: string | number | undefined;
suppressHydrationWarning?: boolean | undefined;
className?: string | undefined;
color?: string | undefined;
height?: string | number | undefined;
... 472 more ...;
key?: React.Key | ... 1 more ... | undefined;
}
IconProps } from "./IconBase";
import function useIcon(iconName: string | undefined): React.LazyExoticComponent<typeof IconBase> | undefined
useIcon, { const iconNames: string[]
iconNames } from "./useIcon";
import type { type StoryFn<TCmpOrArgs = _storybook_types.Args> = [TCmpOrArgs] extends [ComponentType<any>] ? _storybook_types.AnnotatedStoryFn<ReactRenderer, ComponentProps<TCmpOrArgs>> : _storybook_types.AnnotatedStoryFn<...>
StoryFn, type Meta<TCmpOrArgs = _storybook_types.Args> = [TCmpOrArgs] extends [ComponentType<any>] ? _storybook_types.ComponentAnnotations<ReactRenderer, ComponentProps<TCmpOrArgs>> : _storybook_types.ComponentAnnotations<...>
Meta } from "@storybook/react";
export default {
ComponentAnnotations<ReactRenderer, Args>.title?: string | undefined
title: "Icon",
ComponentAnnotations<ReactRenderer, Args>.component?: React.ComponentType<any> | undefined
component: function IconBase(props: IconProps): React.JSX.Element
IconBase,
argTypes?: Partial<ArgTypes<Args>> | undefined
argTypes: {
icon: {
options: string[];
control: {
type: "select";
};
}
icon: {
InputType.options?: readonly any[] | undefined
options: const iconNames: string[]
iconNames,
InputType.control?: Control | undefined
control: {
type: "select"
type: "select",
},
},
},
} as type Meta<TCmpOrArgs = _storybook_types.Args> = [TCmpOrArgs] extends [ComponentType<any>] ? _storybook_types.ComponentAnnotations<ReactRenderer, ComponentProps<TCmpOrArgs>> : _storybook_types.ComponentAnnotations<...>
Meta;
const const Template: AnnotatedStoryFn<ReactRenderer, IconProps & {
icon?: string | undefined;
}>
Template: type StoryFn<TCmpOrArgs = _storybook_types.Args> = [TCmpOrArgs] extends [ComponentType<any>] ? _storybook_types.AnnotatedStoryFn<ReactRenderer, ComponentProps<TCmpOrArgs>> : _storybook_types.AnnotatedStoryFn<...>
StoryFn<type IconProps = {
string?: string | number | undefined;
suppressHydrationWarning?: boolean | undefined;
className?: string | undefined;
color?: string | undefined;
height?: string | number | undefined;
... 472 more ...;
key?: React.Key | ... 1 more ... | undefined;
}
IconProps & { icon?: string | undefined
icon?: string }> = (args: IconProps & {
icon?: string | undefined;
}
args) => {
const { const icon: string | undefined
icon, ...const props: {
string?: string | number | undefined;
suppressHydrationWarning?: boolean | undefined;
className?: string | undefined;
color?: string | undefined;
height?: string | number | undefined;
... 472 more ...;
key?: React.Key | ... 1 more ... | undefined;
}
props } = args: IconProps & {
icon?: string | undefined;
}
args;
const const Icon: React.LazyExoticComponent<(props: IconProps) => React.JSX.Element> | undefined
Icon = function useIcon(iconName: string | undefined): React.LazyExoticComponent<(props: IconProps) => React.JSX.Element> | undefined
useIcon(const icon: string | undefined
icon);
if (!const Icon: React.LazyExoticComponent<(props: IconProps) => React.JSX.Element> | undefined
Icon) {
return <></>;
}
return <const Icon: React.LazyExoticComponent<(props: IconProps) => React.JSX.Element>
Icon {...const props: {
string?: string | number | undefined;
suppressHydrationWarning?: boolean | undefined;
className?: string | undefined;
color?: string | undefined;
height?: string | number | undefined;
... 472 more ...;
key?: React.Key | ... 1 more ... | undefined;
}
props} />;
};
export const const Default: AnnotatedStoryFn<ReactRenderer, IconProps & {
icon?: string | undefined;
}>
Default = const Template: AnnotatedStoryFn<ReactRenderer, IconProps & {
icon?: string | undefined;
}>
Template.CallableFunction.bind<AnnotatedStoryFn<ReactRenderer, IconProps & {
icon?: string | undefined;
}>>(this: AnnotatedStoryFn<ReactRenderer, IconProps & {
...;
}>, thisArg: unknown): AnnotatedStoryFn<...> (+1 overload)
bind({});
const Default: AnnotatedStoryFn<ReactRenderer, IconProps & {
icon?: string | undefined;
}>
Default.args?: Partial<IconProps & {
icon?: string | undefined;
}> | undefined
args = {
icon?: string | undefined
icon: "LightBulbIcon",
};
Now when a user selects an icon name from the icon control in the Storybook interface, React will dynamically import the appropriate icon module and render it when it is available. Meaning only the default selected icon is loaded at runtime.
Three: All Icon Reference
For stories or documentation that show all the icons next to each other for reference, we still have the original issue. Since my previous attempts failed, I decided to go down a level of abstraction and write a plugin (at this point I just wanted things to work). Thankfully, Vite uses a plugin architecture borrowed from Rollup, which I have had a fair amount of experience writing in the past. Furthermore, we know it uses esbuild to transpile source code on the fly, so why not just do that for our index file. Below is roughly what the plugin looks like:
import { interface Plugin<A = any>
Plugin } from "vite";
import { function build<T extends BuildOptions>(options: SameShape<BuildOptions, T>): Promise<BuildResult<T>>
build } from "esbuild";
import * as const path: PlatformPath
path from "path";
export default function function bundleIconsPlugin(): Plugin
bundleIconsPlugin(): interface Plugin<A = any>
Plugin {
let let command: string
command: string;
return {
OutputPlugin.name: string
name: "bundle-icons",
Plugin<any>.config?: ObjectHook<(this: void, config: UserConfig, env: ConfigEnv) => void | Omit<UserConfig, "plugins"> | Promise<void | Omit<UserConfig, "plugins"> | null> | null> | undefined
config(config: UserConfig
config, { ConfigEnv.command: "build" | "serve"
command: _command: "build" | "serve"
_command }) {
let command: string
command = _command: "build" | "serve"
_command;
},
async Plugin<any>.load?: ObjectHook<(this: PluginContext, id: string, options?: {
ssr?: boolean | undefined;
} | undefined) => LoadResult | Promise<LoadResult>> | undefined
load(id: string
id: string) {
// Check we are serving, as this is a development mode optimisation.
// Also check we are loading the index file.
if (let command: string
command === "serve" && id: string
id.String.endsWith(searchString: string, endPosition?: number | undefined): boolean
endsWith("/Icon/index.tsx")) {
const { const outputFiles: OutputFile[]
outputFiles } = await build<{
entryPoints: string[];
bundle: true;
platform: "browser";
write: false;
jsx: "preserve";
absWorkingDir: string;
format: "esm";
plugins: {
name: string;
setup(build: PluginBuild): void;
}[];
}>(options: SameShape<...>): Promise<...>
build({
entryPoints: string[]
entryPoints: [id: string
id],
bundle: true
bundle: true,
platform: "browser"
platform: "browser",
write: false
write: false,
jsx: "preserve"
jsx: "preserve",
absWorkingDir: string
absWorkingDir: var process: NodeJS.Process
process.NodeJS.Process.cwd(): string
cwd(),
format: "esm"
format: "esm",
// We also need a custom esbuild plugin because we want to treat everything as
// external apart from our individual icon files.
plugins: {
name: string;
setup(build: PluginBuild): void;
}[]
plugins: [
{
name: string
name: "externals",
function setup(build: PluginBuild): void
setup(build: PluginBuild
build) {
build: PluginBuild
build.PluginBuild.onResolve(options: OnResolveOptions, callback: (args: OnResolveArgs) => OnResolveResult | Promise<OnResolveResult | null | undefined> | null | undefined): void
onResolve({ OnResolveOptions.namespace?: string | undefined
namespace: "file", OnResolveOptions.filter: RegExp
filter: /.*/ }, (args: OnResolveArgs
args) => {
if (args: OnResolveArgs
args.OnResolveArgs.kind: ImportKind
kind === "entry-point") {
return null;
}
// If the file is in our icons folder use standard resolution.
if (args: OnResolveArgs
args.OnResolveArgs.path: string
path.String.startsWith(searchString: string, position?: number | undefined): boolean
startsWith("./icons/")) {
return null;
} else {
// Otherwise flag is external.
return {
OnResolveResult.path?: string | undefined
path: const path: PlatformPath
path.path.PlatformPath.join(...paths: string[]): string
join(args: OnResolveArgs
args.OnResolveArgs.resolveDir: string
resolveDir, args: OnResolveArgs
args.OnResolveArgs.path: string
path),
OnResolveResult.external?: boolean | undefined
external: true,
};
}
});
},
},
],
});
// Make sure we build something.
if (!const outputFiles: OutputFile[]
outputFiles || const outputFiles: OutputFile[]
outputFiles.Array<T>.length: number
length !== 1) {
return null;
}
// Return the bundled file contents.
return const outputFiles: OutputFile[]
outputFiles[0].OutputFile.text: string
text as string;
}
return null;
},
};
}
Conclusion
And that is it. Three approaches that work in tandem to make Storybook DX so much nicer with large icon sets! I hope this blog post inspired you or solved a problem you were having, either way, thanks for reading.