I have recently updated a rather complex web application. The application has features like auth, Stripe, i18n, dark/light mode, PWA, etc. Overall, it has around 30 pages and components, with almost no third-party npm packages.
I would like to point out what I found quite challenging when migrating the app to Svelte 5.
Auto-Migration Script Hammer
The auto-migration script provided by Svelte can do the job for you with this “one-liner” command in the terminal npx sv migrate svelte-5
(after you do all the necessary updates and installs: “@sveltejs/vite-plugin-svelte”: “^4.0.0” and “svelte”: “^5”). But I do not recommend this “hammer” approach.
Go file by file, component by component with Ctrl + Shift + P (Windows/Linux) / Shift + Command + P (Mac) and use the Migrate Component to Svelte 5 Syntax
command in the VS Code command palette instead. You will have more control that way.
Deprecated run() Surprise
The script cannot perform miracles. Upgrading reactive variable declarations to $state()
is usually fine. However, the script may struggle to detect whether $:
should be converted to $derived()/$derived.by(() => {})
or $effect(() => {})
.
So, guess what? With the auto-migration script, you might end up with lots of run(() => {})
.
For example, I used this pattern in my app:
<script>
...
let notext = false;
$: if (data.completeDoc == 'NoLangVersion') {
notext = true;
}
$: if (data.completeDoc !== 'NoLangVersion') {
notext = false;
}
</script>
...
{#if notext}
{data.userPrefferedLang.noTextWarning}
{:else}
...
{/if}
...
The auto-migration script will give you this:
<script>
import { run } from 'svelte/legacy';
...
let notext = $state(false);
run(() => {
if (data.completeDoc == 'NoLangVersion') {
notext = true;
}
});
run(() => {
if (data.completeDoc !== 'NoLangVersion') {
notext = false;
}
});
</script>
with a nice little warning that the run function is deprecated.
The better Svelte 5 code would be this I guess:
<script>
...
let notext = $derived.by(() => {
if (data.completeDoc == 'NoLangVersion') {
return true;
}
if (data.completeDoc !== 'NoLangVersion') {
return false;
}
});
...
</script>
The reason is that the script cannot transform code to $derived.by(() => {})
easily, so it would like to use a more dirty approach with $effect()
. But $effect()
runs only client-side, so the script uses the deprecated run
function instead.
Avoid $effect If You Can
Now we are getting to the most important takeaway. Which is $effect()
running only client-side. So no $effect()
on the server, for prerendering pages and SSR.
$effect()
DOES NOT RUN ON THE SERVER!
This should be really emphasized in the Svelte 5 documentation.
Look at this two examples:
<script>
let a = 1
let b = 2
$: c = a + b
</script>
{c} // server responds with c == 3
<script>
let a = $state(1)
let b = $state(2)
let c = $state(0)
$effect(() => {
c = a + b
})
</script>
{c} // server responds with c == 0
They are not the same. This causes a lot of challenges. The client will need to reevaluate the c variable when mounting the page. The page will look different when sent from the server and when finally DOM-rendered on the client (SSR, SEO, flicker issues, etc.).
So always try to use $derived or $derived.by(() => {})
over $effect()
. It will save you lots of trouble.
It’s quite the same story as when we were discouraged from using stores in SvelteKit and SSR.
$effect vs onMount() in SvelteKit
You might be tempted to replace your onMount()
in SvelteKit with $effect()
thanks to the examples that were given during the arrival of Svelte 5. For the reasons already mentioned, I would discourage this for the time being. onMount is still a a core Svelte lifecycle hook.
$bindable $props Surprise
The other nice surprise is that Svelte 5 takes care to have consistent variable values. If you pass a variable as a prop to a component and change this variable in the component later on, the script will try to solve this inconsistency using $bindable $prop
. The parent should be notified, so your app state is consistent.
Look at this example:
// parent svelte file
<script>
import ComponentBinded from './ComponentBinded.svelte';
import ComponentWithDerived from './ComponentWithDerived.svelte';
let name = $state('John Wick');
</script>
<p>Name value in parent: {name}</p>
<ComponentBinded bind:name={name} />
<ComponentWithDerived {name} />
The autou-migration script will want you to use a component with binded value to ensure the parent may get the updated value back:
// ComponentBinded.svelte
<script>
let { name = $bindable() } = $props();
name = name.toUpperCase()
</script>
<p>
Name value in component with binded value: {name}
</p>
But maybe we can use quite simpler way as well, you guessed it, with $derived()
:
// ComponentWithDerived.svelte
<script>
let { name } = $props();
let upperCaseName = $derived(name.toUpperCase())
</script>
<p>
Name value in component with derived value: {upperCaseName}
</p>
:global { } Block
A very nice feature that I found during migration was that we can use CSS :global
with block now. Styling with :global
is quite necessary if you want to style the HTML elements in @html
, for example.
So instead of this:
...
<style>
#blog :global(table) {
width: 100%;
}
#blog :global(td) {
text-align: left;
}
#blog :global(th) {
font-weight: bolder;
font-size: medium;
text-align: center;
}
</style>
you can use this:
...
<style>
#blog :global {
table {
width: 100%;
}
td {
text-align: left;
}
th {
font-weight: bolder;
font-size: medium;
text-align: center;
}
}
</style>
Style as a Prop in Components
In Svelte 4, if you wanted to provide a CSS class as a prop to a component, you would use {$$props.class}
:
// Icons Component
<script>
export let name;
export let width = '1.5em';
export let height = '1.5em';
export let focusable = false;
let icons = {
user: {
svg: `<path fill="none" d="M0 0h24v24H0V0z"/><path d="M12 6c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2m0 10c2.7 0 5.8 1.29 6 2H6c.23-.72 3.31-2 6-2m0-12C9.79 4 8 5.79 8 8s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm0 10c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>`
},
user_logged: {
svg: `<path fill="none" d="M0 0h24v24H0z"/><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>`
}
};
let displayIcon = icons[name];
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
class={$$props.class}
viewBox="0 0 24 24"
fill="currentColor"
{focusable}
{width}
{height}
>
{@html displayIcon.svg}
</svg>
<style>
...
</style>
In Svelte 5 you may use class={className}
:
<script>
let {
name,
width = '1.5em',
height = '1.5em',
focusable = false,
class: className = ''
} = $props();
let icons = {
user: {
svg: `<path fill="none" d="M0 0h24v24H0V0z"/><path d="M12 6c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2m0 10c2.7 0 5.8 1.29 6 2H6c.23-.72 3.31-2 6-2m0-12C9.79 4 8 5.79 8 8s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4zm0 10c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>`
},
user_logged: {
svg: `<path fill="none" d="M0 0h24v24H0z"/><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>`
}
};
let displayIcon = icons[name];
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class={className}
{focusable}
{width}
{height}
>
{@html displayIcon.svg}
</svg>
<style>
...
</style>
Possible Lighthouse Perfomance Drop
When I used the auto-merging script, I was shocked at how my app’s performance dropped. With Svelte 4, I had nearly all 100%s. It was only after I manually migrated and carefully considered how (mainly how to avoid $effect()
if possible) that my Lighthouse scores were back in the green again.
Final Words
It took longer to migrate to Svelte 5 than I had expected. I still have not pushed this new version to production, though. The updates to Svelte 5 are still coming in with quite high frequency.
I hope my experience may be useful to others.
Source link
lol