Migrating YouTube Looper to Svelte 5

Wilker Lucio
17 min readSep 14, 2024

--

Hi folks, I’ve been continuing my work on YouTube Looper, and I wanted to migrate it to Svelte 5. First, I want to learn more about it, but also to be more future-proof and performatic.

Now on Svelte 5!

Making it run on Svelte 5

The first step was to bump the library:

pnpm install svelte@next

Since they are compatible, I hoped most things would work out of the box.

As I started the build, I got some immediate warnings, one about a <i> tag that I was using self-closed. Also, they change the module scripts from <script type="module"> to <script module>. These I fixed in this commit.

In my case, I’m manually mounting the components, and that part is a breaking change, so first, I had to fix that too.

After those changes, the app started and ran! Kudos for the compatibility layer. It's nice that I can work incrementally from here to port the code for the new patterns.

Events

Let’s start with something simple, but that might not be obvious to everyone. As you might know already, in Svelte 5, it is time to move from the beloved on:event to the classic onevent format.

The docs also tell us that we will have to learn to live without event modifiers. It's a bummer, but I understand the motivation for the change. They even suggest writing some helpers to deal with preventDefault and stopPropagation. I did, and I think since they used to have the modifiers, it would be a good idea to provide those helpers as part of the library. Here is my version of these; feel free to copy them!

/**
* Stop propagation
*
* This is a helper to use with events.
* There are a few ways this function is intended to be used.
*
* @example <caption>If you just want to stop propagation and do nothing else:</caption>
* <button onclick={sp}></button>
*
* @example <caption>You can also pass a function to do something on top of stopping the propagation</caption>
* <button onclick={sp((e) => console.log('clicked', e))}></button>
*/
export function sp(e: Event | Function) {
if (typeof e === 'function') {
return (e2: Event, ...args: any[]) => {
e2.stopPropagation()
e(e2, ...args)
}
} else {
e.stopPropagation()
}
}

/**
* Prevent default
*
* This is a helper to use with events.
* There are a few ways this function is intended to be used.
*
* @example <caption>If you just want to prevent default and do nothing else:</caption>
* <button onclick={pd}></button>
*
* @example <caption>You can also pass a function to do something on top of preventing the default</caption>
* <button onclick={pd((e) => console.log('clicked', e))}></button>
*/
export function pd(e: Event | Function) {
if (typeof e === 'function') {
return (e2: Event, ...args: any[]) => {
e2.preventDefault()
e(e2, ...args)
}
} else {
e.preventDefault()
}
}

I was changing my events, and for the most part, it was just a matter of removing the : from on:*. One situation was a bit trickier, though. It's an input that allows the user to edit the loop labels. In the previous version, I had this:

<input bind:value={$loopSource.label} on:keydown|stopPropagation={loseFocus} on:keyup|stopPropagation>

Note the usage of the stopPropagation modifier in keydown and keyup events. They are important because otherwise, while the user types the new label, it will be firing YouTube shortcuts.

I thought I could simply replace these with the following:

<input bind:value={loop.label} onkeydown={sp(loseFocus)} onkeyup={sp}>

But it wasn’t working. After some reading of the docs and trial and error, I add the capture modifier, and boom! Now working!

<input bind:value={loop.label} onkeydowncapture={sp(loseFocus)} onkeyupcapture={sp}>

That’s what I have about events; all else was just a boring migration of event declaration styles.

YouTube Looper, TinyBase, and Svelte Stores

I think the most interesting part of this migration was related to stores.

This is the second version of YouTube Looper. I made this version mostly because I got down the rabbit hole of playing with local-first software.

In short, I want the app data to be powered by a reactive local database that can be synced across devices.

After considering some options, I decided to use the TinyBase library for this purpose.

When dealing with reactive values, we want to both load the current data and keep watching for any changes to it, which should be reflected in the UI.

This is where I’ve been using Svelte Stores extensively, so I can hook to parts of the database and get immediate updates if anything changes.

I like to show what a basic example of this integration looks like and explain the important parts:

// in this example I like to show how to use a store to observe a value
// that comes from the TinyBase KV store

export function useValue(store: GenericStore, id: Id) {
// start new readable, get the value from KV store as initial
return readable(store.getValue(id), (set) => {
// here we register a listener with TinyBase, so if that value gets
// change the store will react and update it
const listener = store.addValueListener(id, (store, valueId, newValue) => {
// use the Svelte store `set` to update it
set(newValue);
})

// return this function to clean up the listener when there is no one
// listening to it
return () => store.delListener(listener)
});
}

// note this is conceptually very similar to the first store example on the URL
// that I displayed here at the beginning of the article

I’m getting ahead of myself here. Let’s start talking about stores and see if runes can replace them on Svelte 5.

Stores vs Runes

First, according to Svelte docs, the Stores are not getting deprecated, so it's fine to keep using them. That said, it seems like runes can be used to replace many cases where stores are used on Svelte 4.

So, to improve my understanding, at first, I was putting effort into migrating all stores to runes and getting a feeling for how they work compared to stores.

I have a store that tracks the current URL. I need this on YouTube Looper because I have to detect when the user navigates around. This is the store code:

import {readable} from "svelte/store";

export const locationStore = readable(location.href, (set) => {
let current = location.href;

const interval = setInterval(() => {
if (current !== location.href) {
current = location.href
set(current)
}
}, 500)

return () => clearInterval(interval)
})

IMO, this code is good; it only has the parts it needs, and I feel I don’t have to do any weird twists to make it work correctly. So, on runes land, how can I make the same? Here is the answer:

// also, I had to rename this file from location.ts to location.svelte.ts so
// compiler magic works
export function useLocation() {
let current = $state(location.href)

$effect(() => {
const interval = setInterval(() => {
if (current !== location.href) {
current = location.href
}
}, 500)

return () => clearInterval(interval)
})

return {
get value() {return current}
}
}

The first question I had when doing the runes version was about how to free the resources. With stores, it starts the store on the first subscription and runs the cleanup function once there are no subscribers, and that is pretty good.

To achieve the same kind of behavior with runes, we have to use the $effect rune. The $effect approach has some non-obvious issues, I for example had a race-condition related to it that I’ll talk more about later.

My first impression is that the runes code is bigger and also introduces incidental complexity with the need to have the value indirection to access the content.

Now to the usage, let's compare:

// using the store code
let videoId = $derived(extractVideoId($locationStore))

// using the runes
let location = useLocation()
let videoId = $derived(extractVideoId(location.value))

Here, again, the code using runes became bigger than the one using stores. Even though the store version uses the $ to subscribe, I think that’s a fine trade-off.

On the counterpart, with the runes, I need to know about .value, which might be different depending on the user implementation.

I would like to point out that my first attempt using the runes was to write:

let videoId = $derived(extractMediaId(useLocation().value))

But doing so triggers the following error:

The content script "youtube" crashed on startup! Svelte error: state_unsafe_local_read
Reading state that was created inside the same derived is forbidden. Consider using `untrack` to read locally created state

After trial and error, I realized that separating them works. I had this error happening many times during my migration.

Stores vs Runes: Svelte context

Back to YouTube Looper integration with TinyBase.

In that part of the code, a big change I had to make related to stores was stopping pulling Svelte context from the helpers.

To illustrate this, here is an example of a helper before the migration:


export function useTables() {
const {store} = getContext('tinybase');

return useReader(store, 'Tables')
}

And after:

// now I get the store from an argument instead of context
export function useTables(store: GenericStore) {
return useReader(store, 'Tables')
}

Without this change, I was getting errors about Svelte trying to access context in a moment it couldn’t, like:

Uncaught Svelte error: lifecycle_outside_component
`getContext(...)` can only be used during component initialisation

This also meant I had to change all usages from the helpers to separate the load of the data from context to the call to these helpers. The same wasn’t required when I was using stores. I don’t really understand the root of the issue here, but I believe it's related to when Svelte calls these APIs internally.

I don’t think that’s a bad thing. I think this is a better approach. It also makes my helpers better for re-use since it’s not complected with the data from context.

If you want to have a closer look, this commit includes most of these changes: https://github.com/wilkerlucio/media-looper/commit/36d073c8fa9ebeeb756a025305107b84c910ad4c

Stores vs Runes: Writable stores

So far, I have only talked about replacing readable stores, what about the writable ones?

For YouTube Looper, I use writable stores so that on top of having a reactive state with TinyBase, I can also update the database from the store.

First lets have a look at a simple case, this uses the KV feature of TinyBase to hook on the value of some specific key.

// this is an extension of the useValue I demo earlier, now making it support
// writing as well

export function useValue(store: GenericStore, id: Id, defaultValue?: Value) {
const { subscribe } = writable(store.getValue(id) || defaultValue, (set) => {
const listener = store.addValueListener(id, (store, valueId, newValue) => {
set(newValue)
})

return () => store.delListener(listener)
});

return {
subscribe,
set: (x: Value) => {
// this is the big thing that makes it different, when the value is
// set we call directly the TinyBase to update it
// note we don't need to update the store value itself, because TinyBase
// will trigger a change and our listener up there will pick it up and
// update the Svelte store value
store.setValue(id, x)
}
}
}

This is super cool because it enables me to write something like this:

<script>
import {getContext} from 'svelte'
import {useValue} from 'tinybase-stores.ts'

const {store} = getContext('tinybase')

let userFootnote = useValue(store, 'user-footnote', '')
</script>

<input type="text" bind:value={$userFootnote} />

This will automatically load data from TinyBase and will automatically update the input if the data is changed somewhere else (maybe another component in the same UI, but since I also have external sync, a remote machine could have changed it, and we will get the update back!), and will automatically save to the database on change! So much in this little snippet!

We can rewrite this using runes as such:

export function useValue2(store: GenericStore, id: Id, defaultValue?: Value) {
let x = $state(store.getValue(id) || defaultValue)

$effect(() => {
const listener = store.addValueListener(id, (store, valueId, newValue) => {
x = newValue
})

return () => store.delListener(listener)
})

return {
get value() {
return x
},

set value(newValue) {
store.setValue(id, newValue)
}
}
}

And that enables the same behavior we described before, with small changes in our code that use it:

<script>
import {getContext} from 'svelte'
import {useValue2} from 'tinybase-stores.ts'

const {store} = getContext('tinybase')

let userFootnote = useValue2(store, 'user-footnote', '')
</script>

<input type="text" bind:value={userFootnote.value} />

Stores vs Runes: Nested updates

Now, we get to the first complicated issue I had when replacing stores with runes.

To explain this one, we will use the Row accessor on TinyBase, which loads data for a row from some table.

First, let’s look at the code for this helper. It's very similar to the one from Value that we saw before.

// code using writable store
export function useRow(store: GenericStore, tableId: Id, rowId: Id) {
const { subscribe } = writable(store.getRow(tableId, rowId), (set) => {
const listener = store.addRowListener(tableId, rowId, () => {
set(store.getRow(tableId, rowId));
})

return () => store.delListener(listener)
});

return {
subscribe,
set: (x: Row) => {
store.setRow(tableId, rowId, x)
}
}
}

// code using runes, in the same way we made it before
export function useRow2(store: GenericStore, tableId: Id, rowId: Id) {
let x = $state(store.getRow(tableId, rowId))

$effect(() => {
const listener = store.addRowListener(tableId, rowId, () => {
x = store.getRow(tableId, rowId)
})

return () => store.delListener(listener)
})

return {
get value() {
return x
},

set value(newValue) {
store.setRow(tableId, rowId, x)
}
}
}

Now let's try to use this, first using the writable store version.

<script>
import {getContext} from 'svelte'
import {useRow} from 'tinybase-stores.ts'

const {store} = getContext('tinybase')

let media = useRow(store, 'medias', '123')
</script>

<input type="text" bind:value={$media.label} />

If we look at the TinyBase, we will see this works as expected. The media with ID 123 has its label updated as we change the text from the input.

Unfortunately, the same isn’t true when using the runes version.

<script>
import {getContext} from 'svelte'
import {useRow2} from 'tinybase-stores.ts'

const {store} = getContext('tinybase')

let media = useRow2(store, 'medias', '123')
</script>

<input type="text" bind:value={media.value.label} />

The field value updates, but the change never gets back to TinyBase. It can read the previous value but won't update; why is that?

The thing is, what we return from our useRow2 is a simple object with a getter and a setter for the property value. But once we do media.value.label, that label will no longer have a relationship with our value setter. It means that it will do the $state behavior on that path.

The writable store, on the other hand, is somehow able to capture the path of the binding, update that path part, and fire an event with the whole object from the root to its set function.

I think that quite magical, and I would love to understand how that works (if you, the reader, know it, I would love to hear about it).

Although the writable behavior is magic to me. I clearly understood why my rune version couldn’t work. But I had an idea of how to make it work.

So, I already know that what $state returns is a Proxy that’s able to track changes and signal stuff away. I was expecting that it would also track something like my upward set, which I later realized wouldn’t be possible.

What if I make a proxy that tracks access and updates from the root? And then I made this:

type Path = (string | symbol)[]

type SetterNotification<T> = (snapshot: T, path: Path, prop: any, value: any) => any

export function stateProxy<T extends object>(data: T, setter: SetterNotification<T>, container: any = data, path: Path = []) {
return new Proxy<T>(data, {
get: function (target, prop, receiver) {
const val = container[prop]

return typeof val === 'object' ? stateProxy(data, setter, val, [...path, prop]) : val
},

set(target, prop, value) {
container[prop] = value

setter($state.snapshot(data) as T, path, prop, value)

return true
}
})
}

This proxy of mine will wrap some object, and for any access to a deeper object, we will track the position and trigger the setter callback with the full object from the root, behaving similarly to what the writable does.

Now, we can use that in our useRow2 function:

export function useRow2(store: GenericStore, tableId: Id, rowId: Id) {
let x = $state(store.getRow(tableId, rowId))

$effect(() => {
const listener = store.addRowListener(tableId, rowId, () => {
x = store.getRow(tableId, rowId)
})

return () => store.delListener(listener)
})

// now we return the state wrapped by our stateProxy
return stateProxy(x, (newValue) => {
// this code will be called when any sub-path of x is reassigned
store.setRow(tableId, rowId, newValue)
})
}

Now things get interesting. This version works for both getting and setting the values:

<script>
import {getContext} from 'svelte'
import {useRow2} from 'tinybase-stores.ts'

const {store} = getContext('tinybase')

let media = useRow2(store, 'medias', '123')
</script>

<!-- now it works! -->
<input type="text" bind:value={media.value.label} />

Ok, I made it work, but it feels somewhat brittle. I’m curious, would you use this stateProxy implementation in your application?

Stores vs Runes: Race conditions

Let’s think about the order of execution of the following store code:

export function useValue(store: GenericStore, id: Id) {
// 1 - we get the initial value from store.getValue
return readable(store.getValue(id), (set) => {
// 2 - this is the critical difference as we will see, here is when
// I register the listener, the important part in the case of the store
// is that this initialization function here runs IMMEDIATLY after my
// call to readable, and this guarantees that nothing external will
// happen between the moment I got the first value and start to listening
// for value changes
const listener = store.addValueListener(id, (store, valueId, newValue) => {
// 3 - moment we get a new value and update the store
set(newValue);
})

return () => store.delListener(listener)
});
}

Now let’s compare it to the runes approach:

export function useValue2(store: GenericStore, id: Id) {
// 1 - like before, start it with the current value from tinybase store
let x = $state(store.getValue(id))

$effect(() => {
// 2 - now here, different from when made a readable, this function will
// not execute immediatly. Instead its gonna be scheduled to run later
// this introduces the opportunity for other code to change the value
// we are watching, before we start listening for changes on it. When
// that happens, we have a stale value in our state. And this is what I
// experienced. Let's see how.
const listener = store.addValueListener(id, (store, valueId, newValue) => {
// 3
x = newValue
})

return () => store.delListener(listener)
})

return { get value() { return x } }
}

In YouTube Looper, the initial startup of the extension is pretty simple. It just adds the button to the YouTube toolbar. At this moment, it doesn’t initialize much; all data loading and listening happens when the user clicks to open the modal.

So when the user opens the modal, I load and listen for changes in loops created for the current video. But one other thing happens in the same flow.

Some YouTube videos have chapter annotations, this one, for example:

When the video has chapters, YouTube Looper will automatically create loops for the video's chapters. A simplification of the code goes as this:

<script>
$effect(createLoopsFromChapters)

let loopsContainer = $derived(useQueriesResultTable(queries, queryId, 'loops', ({select, where}) => {
select('startTime')
select('endTime')
where('source', sourceId)
}))
</script>

<div>...</div>

This code with the writable store works as expected. But once I tried to use the runes version, I noticed that when I opened the modal for the first time, it was blank. If I close and open it again, then it shows the loops automatically created from the chapters. Let’s understand why.

To make it visible, I’ll add some logs to createLoopsFromChapters, useQueriesResultTable and useQueriesResultTable2 (the runes version).

export function useQueriesResultTable(queries: Queries, queryId: Id, table?: Id, query?: QueryBuilder) {
if (table && query) {
queries.setQueryDefinition(queryId, table, query)
}

// 1
console.log('get result table initial value')

return readable(queries.getResultTable(queryId), (set) => {
// 2
console.log('add listener to result table')

const listener = queries.addResultTableListener(queryId, (queries) => {
// 3
console.log('updating result table from event')
set(queries.getResultTable(queryId));
})

return () => {
queries.delListener(listener)

if (table && query) {
queries.delQueryDefinition(queryId)
}
}
});
}

export function useQueriesResultTable2(queries: Queries, queryId: Id, table?: Id, query?: QueryBuilder) {
if (table && query) {
queries.setQueryDefinition(queryId, table, query)
}

// 1
console.log('get result table initial value')
let x = $state(queries.getResultTable(queryId))

$effect(() => {
// 2
console.log('add listener to result table')

const listener = queries.addResultTableListener(queryId, (queries) => {
// 3
console.log('updating result table from event')
x = queries.getResultTable(queryId);
})

return () => {
queries.delListener(listener)

if (table && query) {
queries.delQueryDefinition(queryId)
}
}
})

return { get value() { return x }}
}

export function createLoopsFromChapters() {
console.log('creating loops from chapters');

... // the rest of implementation, irrelevant for the experiment
}

Now, let's run it using the store version and see the log output:

Now, with the runes:

Note how it created the loops between our value being read and the listener getting attached! Making the state stale.

One thing that’s still confusing to me is that changing the order of the effects call (trying to make my listener effect run before the create loops effect) didn’t incur any change. For some reason, the effect from my listener always went later here 🤷.

I can fix it by reading from the store again after I start listening to ensure it’s updated.

export function useQueriesResultTable2(queries: Queries, queryId: Id, table?: Id, query?: QueryBuilder) {
if (table && query) {
queries.setQueryDefinition(queryId, table, query)
}

let x = $state(queries.getResultTable(queryId))

$effect(() => {
const listener = queries.addResultTableListener(queryId, (queries) => {
x = queries.getResultTable(queryId);
})

// here, set the file again
x = $state(queries.getResultTable(queryId))

return () => {
queries.delListener(listener)

if (table && query) {
queries.delQueryDefinition(queryId)
}
}
})

return { get value() { return x }}
}

And I think that’s horrible… It feels like I keep using band-aids to solve something that worked plainly when I was using the writable store.

Another possible fix is to start listening before the effect. But I don’t like this option either. My senses tell me somehow, at some point, this will bite me back, especially when we have the store option where I don’t have this concern at all.

Stores vs Runes: What I’ve learned

I surely feel like I learned a lot in this process. It’s interesting to test our expectations when trying this kind of migration. One question that I had was: shall I replace all stores and go full in on runes?

At the moment, my answer is no.

Don’t get me wrong. I like the runes very much. I have a long background with React and the $effect rune feels like home (but I understand they work very differently, but when developing, as long as the concept holds, I think having the familiar concept makes it easy to build upon it).

This experience helped me clarify when I still want to keep using stores. As always, the answer is “depends”. But I also made two rules of thumb, situations where when I see, I’ll weigh the decision towards stores. They are:

  1. When I need to make deep modifications to data while updating an external source from the root of the structure. As a result of the nested data experiment with stores, I can leave the heavy lifting to Svelte instead of making it complicated.
  2. When the data needs both initialization and cleanup. In those cases, given the example with race conditions, I’d prefer stores to handle it.

So, for now, on YouTube Looper, I’m keeping all my stores.

But maybe I got something wrong? Do you have a different idea? If so, please let me know.

Metrics: Build size

I watched a video recently about Svelte 5 generating more compact output when compared to 4, so I wanted to check how that affected my build sizes.

This is the build on Svelte 4:

✔ Built extension
├─ .output/chrome-mv3/manifest.json 641 B
├─ .output/chrome-mv3/dashboard.html 479 B
├─ .output/chrome-mv3/popup.html 547 B
├─ .output/chrome-mv3/background.js 67.26 kB
├─ .output/chrome-mv3/chunks/dashboard-1oF7RcwT.js 115.6 kB
├─ .output/chrome-mv3/chunks/popup-DvhX3QCR.js 3.96 kB
├─ .output/chrome-mv3/chunks/youtube-qrKMPdtp.js 112.95 kB
├─ .output/chrome-mv3/content-scripts/youtube.js 173.56 kB
├─ .output/chrome-mv3/assets/dashboard-DRWsgFdk.css 128.96 kB
├─ .output/chrome-mv3/assets/popup-4QKQgPNz.css 129.31 kB
├─ .output/chrome-mv3/content-scripts/youtube.css 2.67 kB
├─ .output/chrome-mv3/icon/128.png 3.33 kB
├─ .output/chrome-mv3/icon/16.png 424 B
└─ .output/chrome-mv3/icon/48.png 1.41 kB
Σ Total size: 741.1 kB

And on Svelte 5:

✔ Built extension
├─ .output/chrome-mv3/manifest.json 591 B
├─ .output/chrome-mv3/dashboard.html 479 B
├─ .output/chrome-mv3/popup.html 547 B
├─ .output/chrome-mv3/background.js 67.63 kB
├─ .output/chrome-mv3/chunks/dashboard-D_O2CXO4.js 77.64 kB
├─ .output/chrome-mv3/chunks/popup-Cu5nfWRa.js 1.71 kB
├─ .output/chrome-mv3/chunks/youtube-C7Uce4h9.js 119.97 kB
├─ .output/chrome-mv3/content-scripts/youtube.js 177.41 kB
├─ .output/chrome-mv3/assets/dashboard-DRWsgFdk.css 128.96 kB
├─ .output/chrome-mv3/assets/popup-4QKQgPNz.css 129.31 kB
├─ .output/chrome-mv3/content-scripts/youtube.css 2.61 kB
├─ .output/chrome-mv3/icon/128.png 3.33 kB
├─ .output/chrome-mv3/icon/16.png 424 B
└─ .output/chrome-mv3/icon/48.png 1.41 kB
Σ Total size: 712.03 kB

Better, but let’s be honest, in the case of YouTube Looper, it will be barely noticeable.

That’s what I have to say about the migration. I think Svelte 5 is going great. I can’t wait for the final release!

You can find the complete changes on this PR: https://github.com/wilkerlucio/media-looper/pull/13

--

--