Ladle v3
Ladle is a tool designed for building and testing React components through stories. It serves as a seamless alternative to Storybook and is built using Vite and SWC. Our main goal is to ensure it's as swift and user-friendly as conceivable.
Introduced 18 months ago, Ladle is now utilized in 335 different Uber projects with a total of 15,896 stories. The community response has been positive as well:
- 🎯 60,000 weekly downloads.
- 🖊️ 40 contributors.
- 💬 300 folks joined our Discord community.
Having taken our break from Storybook, Webpack, and Babel, today I'm eager to share insights from our journey and introduce the upcoming major release of Ladle. This version emphasizes the open-source community, addresses existing issues, enhances and outlines standard configurations with libraries like Next, Emotion, and styled-components, and establishes future priorities.
What's new in v3?
- SWC (Speedy Web Compiler) is now the default compiler, replacing Babel for transforming React and Typescript in Ladle projects. This shift leads to 2x faster production builds, swift initial startup, and incremenetal builds. We've exclusively adopted it for our internal projects. Furthermore, we've crafted our own set of plugins to replace certain Babel plugins. We're confident in its scalability; however, if preferred, you can revert to the Babel-based @vite/plugin-react.
- Official support for Node v18 and v20, while Node v16 is now deprecated.
- React 18+ is supported. React v17 is now deprecated.
- Added MDX stories and markdown compatibility.
- A streamlined method to mock dates within stories.
- CSS-in-JS libraries function without any extra tweaks. Documented configurations are available for Emotion, Styled-components, BaseWeb + Styletron, Tailwind.
- An outlined setup for Next.js.
- Exported types like
StoryDefault
andMeta
are now interfaces, allowing for extensions.
Recent additions include:
- Global hotkeys for accelerated navigation.
- Prebundled dependencies to prevent later reloads.
- Global
args
andargTypes
. - A specialized control for adjusting backgrounds.
- Enhanced Typescript types via
satisfies
. - HTTP/2 support.
- The
storyOrder
feature to customize story sequences. - Additional features and bug resolutions.
Lessons Learned
The primary catalyst behind Ladle was performance enhancement. Incremental builds transitioned from several seconds or even minutes to mere milliseconds. Startup durations reduced dramatically. And, below, a chart showcases our progression to a 100% Ladle adoption by 08/2023 and its impact on CI build times.
Technically, we've been doing three different migrations at the same time:
- From Storybook to Ladle.
- From Webpack to Vite.
- From Babel to SWC.
Storybook to Ladle
This phase was relatively smooth. Ladle was architectured to be compatible with the majority of Storybook's core APIs and features. As we integrated individual setups, we incorporated missing elements into Ladle. The transition was seamless for about 99% of our stories, requiring minimal changes.
However, Ladle is more strict in some cases. For instance, story titles must be string literals to support automated code-splitting.
Webpack to Vite
Transitioning from Webpack to Vite posed significant challenges, primarily because they operate differently. While Webpack automatically adjusts any JS code for browser compatibility, Vite interacts minimally with the code, leveraging native browser functionalities, primarily ES modules.
Importing Types
It was common for developers to overlook the subtle distinctions between:
import type { Foo } from "baz";
vs
import { Foo } from "baz";
The issue is that Vite will pass the second import into the browser without any changes and browser will complain about non-existing export Foo
since types get stripped from the code before being sent to the browser.
Commonjs Modules
Vite isn't designed for CommonJS modules. We transitioned our codebase to ES modules, replacing occasional require
calls with alternatives like await import
or leveraging SWC's dead code elimination.
import window
import window from "global/window";
will result in
ReferenceError: Cannot access 'window' before initialization
The solution is to rename window
to something else.
Startup time
It turns out that Vite can be quite slow if you need to import thousands of modules. To be fair, it's often a self-inflicted issue. There is a popular pattern where developers create these feature/index.js
god modules that re-export every single module from the feature
folder. Then, they import from feature/index.js
instead of importing individual modules.
This is not a big issue with Webpack since it always bundles code together and it can eliminate code that is not used. Vite, on the other hand, needs to import all these modules individually and each import results in a separate HTTP request. This can make browser quite unhappy when it reaches thousands of HTTP requests.
It's hard to refactor large codebases like that. We partially alleviate this issue by adapting HTTP/2 so browsers can download multiple modules in a single HTTP request. SWC also makes individual module transformations much faster. The Vite team is aware of this issue and they made a lot of changes to make Vite faster over time. This persistent cache plugin should also help.
Babel to SWC
This was the easiest part. SWC is mostly a drop-in replacement for Babel. We had some custom babel plugins we had to migrate into Rust and SWC plugins. I had no previous experience with Rust and the SWC plugin API is still marked as experimental and not that well documented. It also seems less flexible than Babel's plugin API that provides more information about the AST and allows you to modify it more easily (I'm still not sure how to properly track scopes with SWC). However, ChatGPT was extremely helpful and I was able to migrate all our custom Babel plugins into SWC in a few days. There are also a few existing SWC plugins for some popular libraries.
There were some pretty rare (incorrect) syntaxes that Babel happily parses and SWC fails on. Those are pretty easy to find and fix. We also ran into a syntax being compiled differently by Babel (not adhering to the spec) and SWC (adhering to the spec) that caused a subtle runtime error related to class fields. This one took a few days to debug but it was a good lesson to not rely on Babel's non-standard behavior.
Overall, the migration was pretty straightforward. It took a while since we didn't really force it and did it mostly in a spare time. Also, the amount of setups and stories we had to migrate was abnormally large.
What's Next
The secondary reason why Ladle was created was to make our test automation easier. That's why Ladle has statically analyzable meta parameter and first-class programatic API. We might open-source/release more of it, there are some testing concepts described here.
We want to make easier to generate permutations of stories for automated testing. This could mean utilizing typescript types and making some changes to controls API to make it statically analyzable. Ladle also doesn't support component story format 3.0. All these things are somewhat related and we are looking into how to best combine them to improve our internal testing framework.
On the other hand, we have no plans to add support for other frameworks than React since we are React only shop. If you are looking for Vue/Svelte Vite based alternatives, you should try https://histoire.dev/. It is also not our goal to support all the features of Storybook or trying to duplicate its addon ecosystem. Storybook is a great tool and we are not trying to replace it, it's also trying to address some performance issues by introducing vite builder.
Last Words
I extend my gratitude to all the OSS maintainers who've contributed to Ladle's existence. A special thanks to Vite for their continuous support (including Ladle into their ecosystem test suite). Additionally, SWC's emergence as a solid alternative to Babel, courtesy of its recent plugin API, has been instrumental.