Description
Describe the problem
Both Solid and Vue are known for also using (their own version of) signals as their reactive primitives. Now of course, their signals aren't as ergonomic as Svelte 5's signals, because in Vue one has to access a ref's underlying value with .value
, and Solid requires one to use function calls. As opposed to Svelte 5, which offers the possibility to simply treat the variables as if they were simply the raw values that one put into $state()
.
However, the fact that both Vue and Solid give the developer direct access to the signals they create, allows those developers to create entire libraries full of extra reactive primitives, such as VueUse and Solid Primitives, which offer the exact same usage API as Vue's and Solid's native signal implementations.
Svelte 5, in its current form, does not allow this. There's no way to create a let activeElement = $activeElement()
rune for example, that allows one to use activeElement
like a plain variable such as $state()
would offer. (Aside from the detail that you're not allowed to prefix anything with a $
sign anymore, but that's less important.) More importantly, you would always need to design it so that the consumer of your library would always need to use activeElement.value
or activeElement()
to access and / or mutate the underlying reactive value.
I would deeply appreciate if the maintainers of this awesome framework / compiler called Svelte would be willing to offer some way for us developers to return our own reactive primitives / runes that are treated equally to $state
and $derived
. So that we can build libraries full of great reactive utilities like VueUse and Solid Primitives.
Describe the proposed solution
I propose we expand the limitations on the $
prefix a little bit. I understand why you've chosen to disallow new functions with $
prefixes, because of course you might want to add more native runes in the future. So how about normal functions are still not allowed to be prefixed with $
, with two exceptions. A developer is allowed to prefix a function name with $use
, which would signify that this function produces a writable rune similar to $state
. And a developer is allowed to prefix a function with $get
to signify that this function produces a read-only rune, similar to $derived
. (Of course this should also work for the names of arrow-function-constants.) But other than those two prefixes, all other names that start with $
will still be disallowed, to protect Svelte's maintainers' ability to add new native runes at any point in the future.
So let activeElement = $activeElement()
would become let activeElement = $useActiveElement()
. And we make sure that the compiler understands that then activeElement
should be treated the same as if it were created using $state()
. For this, of course, any function created with the $use
and $get
prefix should adhere to a certain contract, so that the compiler will later know how to get and / or mutate the underlying reactive state.
I'm not attached to any specific contract, but one example could be that the compiler enforces the function to always return an object with .value
getters and setters:
// active-element.svelte.ts
export function $useActiveElement() {
let element: Element | null = $state(null);
// Here some code to start tracking
// the active element in the document
// using event listeners of course.
return {
get value() {
return element;
},
// In this case we're returning a setter too,
// but if we wanted to make this rune read-only,
// then all we'd need to change is rename this function
// to `$getActiveElement` and then omit the this setter.
set value(newElement: HTMLElement) {
// focussing the element will
// trigger the event listeners,
// which will then update `element`.
newElement.focus();
},
}
}
So reading activeElement
anywhere, whether in the javascript code or in the template, will turn activeElement
into activeElement.value
in the code generated by the compiler.
So this code:
const el = document.querySelector("#some-id");
if (el) activeElement = el;
Would compile into this code:
const el = document.querySelector("#some-id");
if (el) activeElement.value = el
And this code:
let paused = $state(false);
let activeElement = $useActiveElement({ paused });
Would compile to this, just like how $derived
works:
let paused = $.source(false);
let activeElement = $useActiveElement(() => ({ paused: $.get(paused) }));
Then on the inside of $useActiveElement()
, it should be assumed that if there's ever any argument passed in, it will always be a single argument at most (enforced by the compiler) and it will always be a function that should be treated as a getter function.
The final code could look something like this:
// active-element.svelte.ts
type Options = {
paused: boolean;
}
type Getter<T> = () => T
const defaultOptions: Options = {
paused: false,
};
type FocusableElement = Element & {
focus: VoidFunction;
blur: VoidFunction;
};
const focusableElements = [
"a[href]",
"button",
"input",
"textarea",
"select",
"details",
"[tabindex]:not([tabindex='-1'])",
];
const focusableElementsSelector = focusableElements.join(", ");
function isFocusable(element: Element | null): element is FocusableElement {
if (element == null) return false;
if (!("focus" in element)) return false;
if (!("blur" in element)) return false;
return element.matches(focusableElementsSelector);
}
export function $useActiveElement(customOptions?: Getter<Partial<Options>>) {
let element = $state<Element | null>(null);
const focusedElement = $derived(isFocusable(element) ? element : null);
const options = () => ({
...defaultOptions,
...customOptions?.(),
})
$effect(() => {
if (options().paused) {
return element = null;
}
function onFocusChange() {
// `document.activeElement` returns `Element | null`,
// which is why I typed `element` the same way.
element = document.activeElement;
}
document.addEventListener('focusin', onFocusChange);
document.addEventListener('focusout', onFocusChange);
return () => {
document.removeEventListener('focusin', onFocusChange);
document.removeEventListener('focusout', onFocusChange);
}
});
return {
get value() {
return focusedElement;
},
set value(newElement) {
// @ts-ignore `blur` doesn't exist on type `Element`, that's why I use optional chaining
newElement ? newElement.focus() : element?.blur?.();
},
}
}
As you can see, I'm defining this function in a file called active-element.svelte.ts
, which is necessary by definition, because I will always need to use $state
under the hood in such a function. Which adds another bonus to the contract. You could make the compiler only treat functions prefixed with $use
/ $get
as custom runes if and only if they were imported from a file which includes .svelte
in the filename. This ensures that this naming convention for custom runes does not collide with any 3rd party libraries that did not intend for their functions to be interpreted as custom runes.
Alternative solutions
Never mind, I proposed an alternative solution, but now I actually don't think this alternative is good enough to even consider. But if you really want to read it then you can expand this thing to read it.
We could also try something similar to what Vue tried once. Although since it didn't work for them, I'm not sure if it would work for us. But what if we would allow developers to "runify" any function that returns an object with a .value
getter / setter, by wrapping the function call into a $()
.
So let's rename our $_activeElement
to useActiveElement
and then in a component it could be used like so:
<script>
import { useActiveElement } from 'use-active-element.svelte.ts';
let activeElement = $(useActiveElement());
</script>
<button id="1">Try 1</button>
<button id="2">Try 2</button>
<button id="3">Try 3</button>
{#if activeElement}
<p>{activeElement.tagName} - { activeElement.id || 'Element does not have an ID' }</p>
{/if}
This way I can easily think of a way to distinguish between a writable and read-only rune. For example, like so:
let activeElement = $.writable(useActiveElement());
let activeElement = $.readable(useActiveElement());
Edit
Hmm, you know what, I don't think $.writable(useActiveElement())
is a good idea. Because that would mean that the consumer of a function suddenly decides whether to treat the output of the function as readable or writable. Which is of course wrong, because the author of the function will already have decided whether to make their output readable or writable (depending on whether or not they offered a .value
setter function of course).
Importance
very nice to have
Edit
I just changed the naming convention to $get
prefixes for read-only custom runes and $use
for writable custom runes. That looks clean to me and is also descriptive enough that I imagine people will find it very easy to learn.