At Remix, we know first-hand just how painful it can be to go through a major version upgrade. Especially for something as foundational to your application as the framework or router it's built on. We want to do our very best to provide you a best-in-class upgrade experience -- let's talk about "Future Flags".
Every1 framework (or library) out there will at some point have to introduce a breaking change. Something that will cause your code as it's written today to break on the new version. This could result in a build-time (or even worse, a run-time) error. But these changes are good! It's how our frameworks evolve, get faster, adopt new platform features, implement community-driven feature requests, and so-on.
Out of this inherent need for breaking changes came the Semantic Versioning (SemVer) specification which defines that breaking changes dictate a new major release version. This is great because it lets application developers know when they should expect their code to require changes on an upgrade, versus when they should expect the upgrade to "just work". Remember though, you should always be reading the release notes and not just blindly upgrading 😉.
Conveniently enough, the same day I started writing this article,
@devagrawal09 tweeted the following which generated a relevant thread of the current state of frameworks and their handling of "major rewrites."
It's clear from the thread that folks have varying interpretations of "major rewrites," and that frameworks have done this with varying degrees of success over the years. Part of why things are not cut-and-dry here is that while SemVer gives us a way to communicate when breaking changes exist - we do not have a similar agreed-upon process of how to introduce breaking changes in our frameworks and communicate them to application developers.
Generally speaking the minimal bar for a major SemVer release is a set of release notes which outline the breaking changes in the major release. Ideally these also include instructions on how to go about changing your code to adopt the breaking changes. But that's really it - beyond that there's very little standardization around how to best prepare and help your users adopt breaking changes across major releases.
For that reason, we've seen a variety of different approaches over the years, including but not limited to:
We've seen approaches that work well, and some that don't. However there seems to be a concept consistent across the success stories which is providing a path for application developers to upgrade their applications iteratively. At scale, the inability to iteratively upgrade portions of an application becomes problematic. You end up with a long lived
version-N-upgrade branch that some engineer ends up thanklessly rebasing onto the latest
main branch on a regular basis, and probably pulling their hair out bit-by-bit in the process.
These long-lived feature branches also tend to be very slow to move along. Our stakeholders don't want to stop feature development for a few weeks so we can upgrade our stacks (which are invisible to the customer) - they want to keep shipping new features in parallel. So not only are teams only allocating a portion of their capacity to the upgrade, they're also dealing with the inherent context switching between the old and new worlds. This causes the upgrade to move even slower.
If we look at the how some of the above approaches play out for the application developers, we often see that they all involve some form of a long-lived feature branch which incurs the downsides mentioned above. In all cases, the lifetime of the feature branch is dictated by the number of breaking changes, but even with only a few breaking changes - it can take a bit of time to address those changes in a large codebase.
Migration Guides are generally followed and implemented in a feature branch.
Preparation Versions tend to split the work into 2 feature branches - one to upgrade to the preparation version and another for the major version. This is a marginally better approach, but these individual branches still comes with the same downsides.
Migration Builds and/or backwards compatibility flags do an even better job of eliminating the long lived feature branches, but they still come with 2 aspects that are not ideal. First, they present a bit of underlying technical risk as running two packages side by side (v2 and v2 "back-compat") is not quite the same as running v2 -- so there is a non-zero surface area for bugs to pop up across the inter-communication of the packages. Second, and probably more importantly, these still dump all of the new features (and breaking changes) on you all at once. There's often very little you can do in advance to prepare your codebase for the upgrade and to lessen the impact. Once v2 releases, you can potentially avoid the long-lived feature branch by upgrading to the new version and back-compat package. But then you're playing catch up on your main branch for a while as you adopt the breaking changes iteratively and eventually remove the compatibility build or back-compat flags.
We're not thrilled with any of these approaches, and are hoping that we can provide an even smoother path through major upgrades.
When we first started talking about how to handle breaking changes for Remix, I couldn't help but think back to the Stability without Stagnation talk I watched Yehuda Katz give at Philly ETE 2016. I wasn't an Ember developer but that talk left a huge impression on me2 about how frameworks could ease the pain of new feature adoption on their users through the use of feature flags. Ryan Florence, however, was an active Ember developer so when I mentioned this talk he immediately knew the "Stability without Stagnation" phrase.
Later on in my career working on a Vue SSR application, we were preparing for a Vue 2 -> 3 upgrade and I was very excited to see the Feature Flags they were introducing in their build (although I switched jobs prior to performing that upgrade so I don't know how smooth it went).
We knew at Remix that this concept of feature flags was crucial if we wanted to be able to provide a smooth upgrade experience for our users. But we wanted to go even further than we have before with our OSS. Even in the best approaches above with backwards-compatibility flags - developers are still left with a "here's all the new stuff at once" dump in a major version - leaving them to play catch-up for a period of time. Furthermore this also sort of stacks up all of the v2 code changes one after another, giving you a compacted surface area for potentially nuanced bugs. We wanted to see if we could do better.
At Remix, our goal introducing breaking changes in major versions is two-fold:
Said another way, most approaches we've seen try to give you an off-ramp from v1 to v2 after v2 is released. Instead, Remix aims to provide you a bunch of small on-ramps to eventual v2 features as they are released in v1 releases. If all goes as plan and you stay up to date as new "on ramps" come out, then your code as it's written today will "just work" when you upgrade to a new major version. This effectively makes major version upgrades no more painful than minor version upgrades 🤯.
Additionally, by introducing these features over-time in v1 - we provide a much larger surface area in which application developers can spread out their v2-related code changes.
We understand this is a lofty goal, and we know it may not work out exactly as we plan all the time, but we're serious about stability and want to makes sure that our process is considering the burden a major version upgrade can put on our application developers.
We plan to do this via what we're calling future flags in the
remix.config.js file. Think of these as feature flags for future features (now say that 5 times fast 😉). As we implement new features, we always try to do them in a backwards-compatible way. But when we can't and decide a breaking change is warranted, we don't table that feature up for an eventual v2 release. Instead, we add a future flag and implement the new feature alongside the current behavior in a v1 minor release. This allows users to start using the feature, providing feedback, and reporting bugs immediately.
That way, not only can you adopt features incrementally (and eagerly without a major version bump), we can also work out any kinks incrementally before releasing v2. Eventually we also then add deprecation warnings to the v1 releases to nudge users to the new behavior. Then in v2 we remove the old v1 approach, remove the deprecations, and remove the flag - thus making the flagged behavior the new default in v2. If at the time v2 is released, an application has opted into all future flags and updated their code - then they should just be able to update their Remix dependencies to v2 and delete the future flags from their
remix.config.js and be running on v2 in a matter of minutes.
Future flags can come in one of 2 forms:
future.v2_feature and the lifecycle of a flag will depend on the nature of the change and if it's breaking or not. The decision flow for a new feature looks something like this:
The lifecycle is thus either:
future.unstable_flag -> Lands in v1
future.v2_flag -> Lands in v2
future.v2_flag -> Lands in v2
And for clarification -
unstable_ here does not mean that we think the feature is bug-ridden! It means that we're not 100% confident the API won't undergo some minor changes before it stabilizes. We absolutely want Early Adopters to start using these features so we can iterate on (or gain confidence in) the API.
And furthermore, a
v2_ flag does not mean the feature is bug-free - no software is! This means that we are confident in the API and consider it the stable API for the default behavior in v2. This means that if you update your code to use this new API in v1, you can make your v2 upgrade much smoother.
Here's a list of the current flags in Remix v1 today:
unstable_cssModules- Enable CSS Modules Support
unstable_cssSideEffectImports- Enable CSS Side Effect imports
unstable_dev- Enable the new development server (including HMR/HDR support)
unstable_postcss- Enable PostCSS Support
unstable_tailwind- Enable TailwindCSS support
unstable_vanillaExtract- Enable Vanilla Extract Support
CatchBoundaryinto a single
v2_meta- Enable the new API for your
v2_routeConvention- Enable the flat routes style of file-based routing
We're in the process of preparing for our v2 release, so all
future.unstable_ flags are being stabilized into
future.v2_ flags (except for those which are not breaking changes, like PostCSS/Tailwind/Vanilla Extract support). This includes adding deprecation warnings for apps still using the old way. Once we stabilize them all we'll do a final Remix 1.15.0 release and let that run for a bit to give folks time to opt into any flags they haven't yet added. Then we'll plan to release Remix 2.0.0 and start working on releasing flag-driven Remix v3 features.
In the future, check out the docs on this strategy for an up -to-date listing of active future flags.
"Every" and not "All" because I'm sure there's some library out there like
add that has been humming along at v1.0.0 for years without breaking changes because ... well the semantics of mathematics don't change all that frequently. But you get the idea - things evolve and require breaking changes, unless you're the DOM which does a wonderful job with backwards-compatibility. ↩
This may have been so relevant to me because just 2 months before that talk AngularJs 1.5.0 had been released in an attempt to provide a smoother path to Angular v2. At the time, I was the lead developer on a large AngularJs 1.4.0 e-commerce checkout app and we were well on our way to realizing that Angular v2 was not going to be an upgrade, but rather a full-rewrite 😕. ↩