Engineering

W-Key in Frontend: Synergizing Technology and Product

W-Key in Frontend: Synergizing Technology and Product

When I joined the Medal Frontend team in 2024, technology and product were blocking each other. Legacy components prevented us from upgrading dependencies, while new features increased the effort to refactor the offending code.

For League of Legends players, it was like running Evelyn ADC and Kassadin Support - anti-synergy and MIA pings everywhere.

But like I said, and always say:

Still winnable though - RicksterGG

TextInput

Hands up if you’ve ever seen a component like this:

A good component is somewhat subjective, but I feel that everyone has a great handle on what a bad component might look like:

  • Too many props
  • Does too many things which makes it unclear what it actually does
    • Some props native to the DOM API
    • Some props being passed in from Narnia
  • Defaults are everywhere which obscures errors when things actually go wrong
  • Architectural questions like why does a TextInput need a closeIcon? Do we not COMPOSE here?!
    • At the same time, wouldn’t clearable be a better name for it?
  • The yung render function… You can extrapolate what the render function was like, but I don’t want to turn into a Deogen from Phasmo and spook the crap out of you.

Not only is the application’s performance negatively impacted by these old components, but it was also frustrating for developers to iterate. Changing a single thing became a nightmare, there were too many corners to check, and while you point a ticket at a 1 or 2 for complexity, shipping the feature simply took much longer than anybody would like to hear.

This component among many others, would be the crux of the issue but instead of tackling these components directly, the path to dousing the fire would start from outwards and move in. Macro, then micro.

Macro

When tackling these issues, it’s unfortunate for the only learning being: “don’t do this.” The happiness and growth of engineers should be a universal KPI, so modernizing the developer experience became my sole focus. That way, it’s less “don’t do this” and more “ouuuu, shiny.”

We had much lower hanging fruit to address before we even get to components like TextInput. The developer experience is so much more than just nice shiny components to work with. The technical roadmap to snowball into Medal’s final form like a late-game Kayle would involve nearly every aspect of the code base.

It started with Macro. Investing on investments (like the Crown in Vampire Survivors)

PNPM and Vite

Pushing a simple change to a versioned component library was not the move! Our library versions would get mixed up, we weren’t sure what was shipped and when, not to mention the endless merge conflicts from engineers working on the same component library. Thus, we migrated our code into a monorepo and started using PNPM for its performance benefits.

Despite all the code being in one place, Medal’s Electron app was stop-start on local machines - you could not see your changed code propagate to your screen. Ken Colton, our CEO, made the move to build our application locally with Vite and take advantage of Hot Module Reloading (HMR). This was a huge milestone in two big pain points of the team, I would say it was the moment we discovered fire. We are humans and Ken was cosplaying Prometheus.

Yeetus deletus

We were able to see what could be deleted without booming the app. Deleted code is code you no longer need to maintain in every aspect: converting to typescript, maintaining good performance, and general modernization, so finding code to delete was the first priority.

The standard ctrl + shift + F was tried and true, but it was around this time I harnessed the power of AI to systemically correct import paths. However, it occasionally made mistakes by flagging false positives and false negatives.

After some iterations, I finally shipped a giant PR to make every import path comprise of: the package it came from, a clear path, and a file extension. Relative imports were still used but there were less barrel files (since these were horrible for code splitting). Kind of like rounding a mob before a fireball jutsu.

With direct paths and file extensions, every file would now be referenced directly. It was much easier for the AI agents to understand and find truly dead code. When we developed that confidence, we started deleting code like mad.

Pentakill!

Simultaneously, we consolidated code across Electron and Web to further DRY up the codebase. Client-agnostic packages like utils, hooks, and types were born as a result. These weren’t code deletions per se, but were probably the next best thing.

Vite in Production

We added Vite but we were still using Rollup for packaged production applications. It was time to unify our build processes and after what seemed like an eon of fighting with native modules and externalizing dependencies a production build made entirely from Vite was born, while local stayed in tact. With a single build system, we were ready to tackle the big bundle since moving to Vite allowed us to split code much easier with Glob Imports.

Green Carpet

My Valorant match history is usually red - so red that I feel like I’m laying out the carpet for royalty but not this time. We went on a win streak reducing the bundle size.

  • Removing the component barrel file: -2.6MB
    • Adopted a death to barrel files policy after this one.
  • Externalized a SoundAlertsData file (.wav) files: -3.4MB
  • Preferring ESM in Rollup, leading to better tree-shake: -4 MB
  • Dynamically importing our i18n translation files: -13MB
  • Code Splitting by Route in Renderer: -5.4MB
    • Worth noting a giant refactor of React Router v5 → v7 to enable this

At this point our bundle was reduced to about 22MB, reduced by over half of our total since the beginning of my journey. I wanted to get us over to Rolldown for faster build times, but some dependencies needed to be deleted first as they brought some conflicts with Rolldowns bundling system. This was gonna be a slow burn but the juice was worth the squeeze. Turns out, switching to rolldown and continuously killing old dependencies took us all the way to a renderer bundle of…

2.7MB!

Catching Up

We were in search of a new library to replace the previous components that were mostly based off Grommet and Styled Components. We adopted Tailwind and Shadcn (Radix UI) and created guides on how to translate old props in old components to new ones. We also flagged the old components with a custom ESLint rule and ample documentation saying the were not to be used with mentions of what to replace them with.

This allows us to finally catch up with the runaway target, ensuring that the pace of new features doesn’t outpace the cleanup. With the continuous removal of old components, we started to slim down the dependencies of the application, while suiting up our application for big upgrades like React Router v5 to v7.

Once the dependencies played nicely with Rolldown, our builds have never been faster. More and more of our code is in TypeScript, our tests are all on Vitest, and while Grommet and Styled Components remain in the app, removing them has never been easier and it’s just a matter of time.

Our renderer bundle is now down to 2.7MB, with plenty of code chunked out and loaded only when necessary. We recently made another migration over from Radix UI to Base UI, and even our QA team has noticed performance improvements and overall snappiness throughout the app.

There’s still more to do, but we can stand up entire pages in a day, it’s amazing to see how far we’ve come. Now, shipping features is a matter of sprinting to the finish line, with fresh kicks and no regard for blockers.