Migrating from Svelte 1 to Svelte 3
This is an account of upgrading a large CMS across some large major-version dependency changes, and how important it is to not be afraid to learn to read the source. It might not be very valuable if you don’t use any of this tech stack, but I’ll attempt to keep it interesting.
Background #
As part of my early work at From Now On, we migrated from a website served with Express that used Pug for templates, to a single page app (SPA) that used a Koa-powered backend and Svelte and the abstract state router for the frontend.
The migration went well: we were able to transition several dozen complex pages of a content-management system (CMS) without impact to users, and without impact to feature development for the company. I count it as a solid win.
However… along the way, we did not prioritize upgrading our dependencies. Although we’ve built some cool and valuable features in that time, Svelte has gone from version 1 to version 3, and the changes between those versions are very significant. As of this writing we are still using version 1, which doesn’t even have documentation available online except through the Internet Archive.
P.S. I made a tidied-up backup because we’re still using it.
The SPA Design #
A brief background on the design of our SPA may make this story a little clearer.
Like many SPAs, the HTML that is served up is essentially an empty page, which loads the bundled JavaScript application:
<!doctype html>
<html>
<head>
<title>Business Webapp</title>
</head>
<body>
<main></main>
<script src="/path/to/bundled.js"></script>
</body>
</html>
The abstract state router is a very solid routing library. It uses hash-based routing, which means URLs look like:
https://site.com/#/app/players/1234
In this design, navigating to site.com
serves that HTML file, which loads the bundled JavaScript file, which is then responsible for interpreting the hash fragment /app/players/1234
and loading the appropriate route.
A little more context is probably required: routes in the abstract-state-router are given a “template”, so for Svelte (both in 1 and in 3) you might declare a route like this:
import PlayerEdit from './PlayerEdit.svelte'
const router = initialize_router_somehow()
router.addState({
name: 'app.players.playerId',
// other properties, and then
template: PlayerEdit
})
Importantly, Svelte doesn’t have a “run time” the way that most of the other popular frameworks do. This ends up being a major advantage when considering a major-version upgrade, as I’m about to describe.
How to Upgrade? #
Initially, the upgrade path I proposed was to maintain two SPA webapps, with two different JS/CSS/etc bundles, and slowly transition the SPA that used Svelte 1 to the one that used Svelte 2. Given the above design, we would have ended up with site.com/app
serving up the old bundle, and site.com/appv2
serving up the new bundle.
This would have been workable, although tricky. I’d probably end up writing some helper functions to map links across the different sites, and (while both existed) we would necessarily have to put up with some less-than-ideal UX transitions for links across webapps.
But I got to wondering… could Svelte 1 and Svelte 3 live together in perfect harmony?
It would likely mean that there was some extra bytes of overhead while we were transitioning our internal library of components, but if we didn’t have to introduce a bunch of technical debt for routing and so on, it would probably make that migration a lot easier.
How it Happened #
After some conversations with other developers, including some longer ones with the creator of the router, I tried to make it work.
The rest of this article basically describes the technical aspects, so if you’re not that interested, let me leave you with a thought: doing a similar thing with any of the mainstream webapp frameworks (aka Angular, React, Vue) would have been at best incredibly more difficult.
Aliases in npm #
The first problem to solve is how to install multiple versions of Svelte, since the package.json
file doesn’t seem to allow it.
With thanks to @lukeed I found that npm supports “aliases”, which look like this:
npm install svelte1@npm:svelte@^1.x
The command is:
npm install
your usual commandsvelte1
the alias@...
install a specific versionnpm:
install from npm (other ones aregithub:
andbitbucket:
)svelte@...
the specific version of svelte
Then, inside your JS files you can require
the aliased package like this:
const svelte1 = require('svelte1')
It turns out that there are a handful of other spots inside the Rollup plugin that do something like require('svelte')
but these are resolved for Svelte 3 if you install it the normal way:
npm install svelte
Compile Svelte Components #
We are using Rollup.js for bundling our webapp, which means that (for Svelte) we use the rollup-plugin-svelte
to turn Svelte files into JS.
However, for that plugin Svelte was listed as a peer dependency, and locating the Svelte compiler was done inside the plugin. This meant that there wasn’t a direct way to take advantage of npm aliases.
Even after working through an issue (thanks to the other contributors!) the plugin code required some additional changes to make it work.
Basically, the plugin accepts a specific imported version of Svelte. Since all of our components have a .html
extension, we can use Svelte 1 to compile .html
files and Svelte 3 to compile .svelte
files. The rollup.config.js
file looks like this:
import path from 'path'
import svelte from 'rollup-plugin-svelte'
// Svelte 1 is from the alias
const svelte1 = require('svelte1')
// Svelte 3 is installed normally
const svelte3 = require('svelte/compiler.js')
// some fiddly bits to get the hacked plugin working
svelte1.version = 1
svelte3.version = svelte3.VERSION
export default {
input: 'app/main.js',
output: { /* ... */ },
plugins: [
svelte({
svelte: svelte1,
// for Svelte 1 we need to manually set the "shared" bits
shared: path.resolve('./node_modules/svelte1/shared.js'),
extensions: [ '.html' ]
}),
svelte({
svelte: svelte3,
extensions: [ '.svelte' ]
})
]
}
Abstract State Router #
The routing library we use (asr) is very stable, and very awesome to work with. It’s a routing library that is (as the name says) abstract, so it requires a rendering library for whatever framework you’re using.
The official one is the svelte-state-renderer and it works great, but it doesn’t support both Svelte 1 and 3. However, the code is relatively straightforward, and it wasn’t too hard to patch in support for both.
You can install this patched version using:
npm install github:saibotsivad/svelte-state-renderer#b155cefab2fbf0902dffa700f9aa345d02db7721
Limitations #
It works! 🎉
However… you can’t embed a Svelte 3 component inside a Svelte 1 component, and probably not the other way around either.
In other words, inside a Svelte 1 component:
<script>
import MyThing from './MyThing.svelte' // v3
export default {
components: { MyThing }
}
</script>
<MyThing />
This won’t actually work, because of how component initialization uses different properties.
I started looking into making a shim of sorts, so maybe something like:
import MyThing from './MyThing.svelte' // v3
import shim from './v3-to-v1-shim.js'
export default {
components: { MyThing: shim(MyThing) }
}
But (for us at least) we were able to migrate individual routes one at a time, since each route in the abstract-state-router is a component in it’s own sort of context. Because of that, we’re just picking pages and sets of components and migrating those in chunks.
It looks something like this:
const router = initialize_router_somehow()
// Svelte 3 component
import PlayerEdit from './PlayerEdit.svelte'
router.addState({
name: 'app.players.playerId',
template: PlayerEdit
})
// Svelte 1 component
import PlayerList from './PlayerList.html'
router.addState({
name: 'app.players.list',
template: PlayerList
})
This works pretty well, if you have your routes split up into small chunks.
However, you’ll probably end up with duplicate components for a while. For example, we have a FormTextInput.html
v1 component and also FormTextInput.svelte
as the v3 component.
This does add additional overhead to your webapp, and depending on how many duplicated components you have, it can become significant.
A solution to this is simply to be careful about strategizing which routes to migrate first (pick ones that won’t have too many duplicated components), and then simply push hard to finish the migration–there might be a company temptation to not take the migration work seriously, since “it works!” but running both is not without download cost and execution performance cost.
Closing Remarks #
I would not recommend running two versions of Svelte in one webapp, but the atomic nature of the router we’re using, and the compiled nature of Svelte, mean that it’s possible! Incremental transitions are a huge factor in project success, and being able to run both means our team can continue to squash bugs and develop new features, even while migrating deeply entrenched code.
Many thanks to @lukeed and @tehshrike for listening to me talk about this problem for long enough that we came up with a possible (and now real!) solution.
Don’t be afraid to read the source when you’re trying new things. Getting better at reading other open source libraries has been a skill that paid off immensely over the years.