Skip to content

Let the community create custom runes that offer similar ergonomics as Svelte 5's native runes #11014

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
Evertt opened this issue Apr 1, 2024 · 8 comments
Milestone

Comments

@Evertt
Copy link

Evertt commented Apr 1, 2024

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.

@Evertt Evertt changed the title Give the community some way to create more reactive primitives that offer the same ergonomics as Svelte 5's runes Let the community create custom runes that offer similar ergonomics as Svelte 5's native runes Apr 1, 2024
@7nik
Copy link
Contributor

7nik commented Apr 1, 2024

Here is the implementation of your code using destructured $derived.by (ignore temp - cannot inline rn due to a bug).

@Azarattum
Copy link
Contributor

I think it should be done by making a third party package a vite plugin. Similar to what dark runes does. The key difference is that it shouldn’t be a hack around the compiler but a supported extension with convenience APIs. At least plugin authors should get access to the AST. Maybe some inspiration could be taken from Rust macros

@Evertt
Copy link
Author

Evertt commented Apr 1, 2024

I think it should be done by making a third party package a vite plugin.

But then, won't the Svelte language server that powers the VSCode plugin for Svelte constantly complain about runes it doesn't recognize?

@Rich-Harris Rich-Harris added this to the 5.0 milestone Apr 1, 2024
@Azarattum
Copy link
Contributor

If this were to be officially supported then plugins could register the new runes with svelte which would tell the language server how to handle them.

@Evertt
Copy link
Author

Evertt commented Apr 2, 2024

If this were to be officially supported then plugins could register the new runes with svelte which would tell the language server how to handle them.

If I understand your message correctly then I'm all for it. So you mean that 3rd party libraries would need to write their own vite plugins that would, similar to Svelte's own compiler, compile custom runes into more normal javascript? And then all you're asking is for Svelte's vite plugin to offer a way to register our custom runes so that it can let the language server know about our newly created custom runes and how to interpret them? Did I understand you correctly?

If yes then I'm all for it. I don't mind having to write a plugin / pre-compiler for Vite. As long as I get the ergonomics of native runes in the end.

Though I imagine that if the maintainers of Svelte would want to allow / support this, that they might want to enforce some rules about how these custom runes are allowed to function etc. And if that's the case then I do think a tighter integration of this feature in Svelte's own compiler will be necessary.

@Rich-Harris
Copy link
Member

This is a topic that the maintainers have discussed intensively, and thus far we've come down on the side of not doing it. The design outlined here imposes a lot of constraints (naming conventions, a single value property, strong opinions over what you can pass into one of these custom runes) that I think would limit flexibility.

For example: we haven't implemented state-based replacements for the tweened and spring stores yet, but a likely design looks something like this:

<script>
  import { spring } from 'svelte/motion';

  const progress = spring(0);
</script>

<progress value={progress.current} />

<button
  onclick={() => {
    // we want `progress.target` to be read/write, but `progress.current` should be readonly
    progress.target += 0.1;
  }}
>next</button>

If this were a $useSpring() rune instead, what would the value getter return — the current value? How would you set the target value? Or would it return an object with getters for current and target (and a setter for target)? What about updating the stiffness and damping properties?

By just using getters and setters we're able to have all this flexibility, and we don't need to make the framework itself more complicated.

Personally I would choose this...

<script>
  import { activeElement } from 'svelte-use';
</script>

<p>{activeElement.current?.nodeName ?? 'no active element'}</p>

...over this:

<script>
  import { $useActiveElement } from 'svelte-use';

  let activeElement = $useActiveElement();
</script>

<p>{activeElement?.nodeName ?? 'no active element'}</p>

There's less conceptual overload, less code, and it better reflects the singleton nature of activeElement.

@braebo
Copy link
Member

braebo commented Nov 23, 2024

When you say:

we want progress.target to be read/write, but progress.current should be readonly

Why do we want that? I can't think of a time when I've wanted that, and I use tweened quite a lot.

I'd assume the simplest, most intuitive api would just be .value, no?

<progress value={progress.value} />

<button
  onclick={() => {
    progress.value += 0.1;
  }}
>next</button>

@Rich-Harris
Copy link
Member

That's how the stores work today, yeah. But I think it's weird if you read a value that you just assigned and it's something different:

progress.value += 0.1;
console.log(progress.value); // still the previous value because nothing has updated yet!

And if I hammer the '+1' button repeatedly, I want the target value to increment by 0.1 — in other words if I press it 10 times, the value should eventually end up at exactly 1. But if I do progress.value += 0.1 and by the time I click the button the second time progress.value is only at 0.05, then the target value will be 0.15 instead of 0.2. And then (say) 0.22 when it should be 0.3, and so on.

Treating target and current as separate things will avoid a lot of handwaving when we try to explain how any of this works.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

6 participants