Skip to content

feat: add $lazy rune #11210

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

Closed
wants to merge 5 commits into from
Closed

feat: add $lazy rune #11210

wants to merge 5 commits into from

Conversation

trueadm
Copy link
Contributor

@trueadm trueadm commented Apr 17, 2024

This is an experimental PR, it might never be merged

This is mainly to get a feel for what an API of this kind would feel like

This PR introduces a new rune called $lazy. It's designed to be a shorthand replacement for writing out getters/setters on object expressions (although, this rune is also supported on arrays too, which is much harder to write with getters/setters).

The rune has double fun meaning (because we're Svelte) – people using this are probably a bit lazy when they could have maybe also written getters/setters instead. :P

The $lazy rune can only be used in object/array expressions. If there it is only passed a single argument than the object property is read-only. If you pass the second argument, it must be a function setter. As shown:

let count = $state(0);
let double = $derived(count * 2);
 
let object = {
    count: $lazy(count, value => count = value),
    double: $lazy(double),
};

Or arrays:

let count = $state(0);
let double = $derived(count * 2);
 
let array = [
    $lazy(count, value => count = value),
    $lazy(double),
];

You consume the lazy properties just like any other properties, there's no .value or some boxing/unboxing magic going on.

Copy link

changeset-bot bot commented Apr 17, 2024

⚠️ No Changeset found

Latest commit: fe9c395

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@huntabyte
Copy link
Member

huntabyte commented Apr 17, 2024

Hey Dom, appreciate you spending time trying to solve this!

Is this core idea for this to avoid having the .value? I worry this solution could be much more confusing/limiting than the following:

// component.svelte
let count = $state(0);
let double = $derived(count * 2);

const countBox = $box(count, (v) => count = v)
const doubleBox = $box(double)
       // ^? { value: number } to the consumer (getter/optional setter for reactivity)

doSomethingWithCount(countBox)
doSomethingWithDouble(doubleBox)
// doSomething.svelte.ts

function doSomethingWithCount(count) {	
	$effect(() => {
		alert(count.value)
	})
}

function doSomethingWithDouble(double) {
	$effect(() => {
		if (double.value > 100) {
			alert('slow down cowboy')
		}
	})
}

With $lazy, it's more difficult to split off different parts of that obj, for example:

// component.svelte
let count = $state(0);
let double = $derived(count * 2);
 
let object = {
    count: $lazy(count, value => count = value),
    double: $lazy(double),
};

// do we have to pass in the whole obj to retain reactivity/mutability inside the fn?
doSomethingWithCount(object)
doSomethingWithDouble(object)

So we end up with something like this if we don't want to pass the entire obj:

// component.svelte
let count = $state(0);
let double = $derived(count * 2);

let countBox = {
	value: $lazy(count, (v) => count = v)
}

let doubleBox = {
	value: $lazy(double)
}

doSomethingWithCount(countBox)
doSomethingWithDouble(doubleBox)

You could argue in the example above we could do:

doSomethingWithCount({ value: $lazy(count, (v) => count = v )})
doSomethingWithDouble({ value: $lazy(double)})

but this same process would have to be applied each place the count or double gets passed leading back to having it defined in a standalone object with a value property.

@dummdidumm
Copy link
Member

@huntabyte how's that limiting compared to $box? Isn't this even more flexible? $box gives you a { value } object, $lazy attaches a getter (and possibly setter) whereever you want it to, so $box(foo) == { value: $lazy(foo) }.

@huntabyte
Copy link
Member

huntabyte commented Apr 17, 2024

@huntabyte how's that limiting compared to $box? Isn't this even more flexible? $box gives you a { value } object, $lazy attaches a getter (and possibly setter) whereever you want it to, so $box(foo) == { value: $lazy(foo) }.

Apologies, my example wasn't clear. $box() in my example would give you a getter and (optionally) a setter as well. Just without requiring it being within an object. The { value: number } was just to demonstrate how it would be accepted on the consumer side (within doSomethingWithCount).

@TGlide
Copy link
Member

TGlide commented Apr 17, 2024

Love that this is being pushed forward! What I'm worried about is the consuming part.

createToggle() 
// or
createToggle({pressed: $lazy(pressed, v => pressed = v)})
// both should be allowed


function createToggle(props) {
  let pressed = $state()
  // how to sync with props?
}

A $lazy.consume or $box.from(props.pressed, false) would be ideal

@huntabyte
Copy link
Member

But what if you just want a getter and no setter.

Optional as well (example updated).

Literally the exact same as $lazy() but without the requirement of it being in an object and for it to create an object with .value for some form of consistency / expectations in APIs.

@Conduitry
Copy link
Member

Is there a particular reason for this to work with arrays, or is it just because we can? That feels like a really weird use to me. It seems like extra code in the compiler to support code we probably don't want to encourage.

The object one seems nice.

@trueadm
Copy link
Contributor Author

trueadm commented Apr 17, 2024

Is there a particular reason for this to work with arrays, or is it just because we can? That feels like a really weird use to me. It seems like extra code in the compiler to support code we probably don't want to encourage.

The object one seems nice.

I much prefer using tuples for many patterns over objects. It's nicer when passing around values that are read-only by design (using destructuring) as you can define the variable name without needing to do object. each time. It's a great way of passing around primitive values without needing the .value stuff.

@abdel-17
Copy link

I agree with Thomas and Hunter here. Having a $box rune is much nicer imo than $lazy.

@abdel-17
Copy link

abdel-17 commented Apr 17, 2024

How would that be different to what you can achieve using $derived and $derived.by though?

Derived can't be passed around without manually wrapping it in an object.

@TGlide
Copy link
Member

TGlide commented Apr 17, 2024

Alright, I think this actually is more flexible than I thought.

function box<T>(get: () => T, set: (v: T) => void) {
	return {
		value: $lazy(get, set)
	}
}

function lazyArgs<O extends Record<string, unknown>, D extends Partial<O>>(object: O, defaults: D = {}): O {
	return Object.keys({ ...object, defaults }).reduce((acc, key) => {
		if (key in object) {
			return { ...acc, [key]: $lazy(() => object[key], (v) => object[key] = v) }
		} else {
			const state = $state(defaults[key])
			return { ...acc, [key]: $lazy(() => state, (v) => state = v) }
		}
	}, {} as O)
}

export function createToggle(_props: ToggleProps = {}) {
	const props = lazyArgs(_props, {
		pressed: false,
		disabled: false,
	});

	// ...
}

const toggle = createToggle() 
toggle.pressed // false
// or
let pressed = $state(true);
const toggleLazy = createToggle({pressed: $lazy(pressed, v => pressed = v)})
toggleLazy.pressed // true

@huntabyte
Copy link
Member

huntabyte commented Apr 17, 2024

Alright, I think this actually is more flexible than I thought.

I'm more so focused on the expectation for the countless library APIs that will emerge, and how consumers will take that magic object and use it with them, for example:

import { superCounter } from 'super-counter'
import { countHog } from 'count-hog'

const myMagicObj = {
    count: $lazy(() => count))
}

$effect(() => {
	if (myMagicObj.count > 100) {
		alert('Woah, slow down!')
	}
})

// magic counter expects a boxed { value: number } with getters and setters
superCounter(myMagicObj.count?)
// no wait
superCounter(myMagicObj)
// shit... still not working
superCounter({ value: $lazy(() => count)})
// ahh there where go!

// countHog expects a boxed { counter: number } with getters and setters
countHog({ counter: $lazy(() => count)})

Without prescribing some sort of "recommended/expected" (not forced) way for libraries to accept these "boxed" values, there could be countless ways a project may want to receive those values which adds mental burden onto the users, especially if they are using multiple libraries with vastly different ways of accepting such.

@Antonio-Bennett
Copy link

@huntabyte I mean from my perspective as a user, that’s fine? Documentation exists for a reason and I don’t really expect everybody to have the same exact patterns? I would imagine though that the best patterns will eventually become a pseudo standard themselves. I honestly think lazy provides more flexibility here especially since this will also be used by regular devs who aren’t focused on building libraries in there use cases it seems much better dx and svelte like vibe being able to just interact with the values instead of needing a .value property

@levibassey
Copy link

levibassey commented Apr 17, 2024

@huntabyte I mean from my perspective as a user, that’s fine? Documentation exists for a reason and I don’t really expect everybody to have the same exact patterns? I would imagine though that the best patterns will eventually become a pseudo standard themselves. I honestly think lazy provides more flexibility here especially since this will also be used by regular devs who aren’t focused on building libraries in there use cases it seems much better dx and svelte like vibe being able to just interact with the values instead of needing a .value property

Exactly, most of us are not building libs. For me $lazy is sufficient

@Hugos68
Copy link
Contributor

Hugos68 commented Apr 17, 2024

@huntabyte I mean from my perspective as a user, that’s fine? Documentation exists for a reason and I don’t really expect everybody to have the same exact patterns? I would imagine though that the best patterns will eventually become a pseudo standard themselves. I honestly think lazy provides more flexibility here especially since this will also be used by regular devs who aren’t focused on building libraries in there use cases it seems much better dx and svelte like vibe being able to just interact with the values instead of needing a .value property

Exactly, most of us are not building libs. For me $lazy is sufficient

You're right that for non-lib-authors it's plenty, but that doesn't change the fact that in most apps the majority of the code comes from libraries. Given that Svelte 5 aims to close the gap between component and module reactivity a lot more can be done inside modules and thus a lot more can be done in libraries. So the fact Svelte doesn't have any predefined way of handling cross module reactivity is a huge design flaw. $lazy aims to solve this but does so partially, pointed out by the points above. IMO the main problem comes from $state returning a flat value unlike a closure like literally every other framework out there does. If $state itself would return a getter/setter tuple or .value object all of this wouldn't have been needed.

For example:

Solid

const [count, setCount] = createSignal(0); // count is a function thus in a closure, reactivity is kept

Vue

const count = ref(0); // Count is wrapped in a `.value` object and thus in a closure, reactivity is kept

Svelte 5

const count = $state(0); // Count is a primitive, not in a closure, thus reactivity is not kept

Why does Svelte 5 need to be so different?

Needing to write count() or count.value is much less boilerplate than writing getters and setters everywhere. It also removes the need for a $box or $lazy rune all together and unifies all library code with the same API.

@huntabyte
Copy link
Member

huntabyte commented Apr 17, 2024

Exactly, most of us are not building libs. For me $lazy is sufficient

Understood 100%. But keep in mind most apps rely heavily on libraries (whether internal or external). Some of the limitations/hacks required to accomplish things that are simple in other frameworks in previous versions are probably why most libs get abandoned after 6 months or are unable to reach their full potential.

I want the experience to be as good as it can be for whatever you're using Svelte to do, which is why I'm perfectly fine with $state() not returning an object or getter/setter tuple, I just want a first party syntactic sugared way to pass reactivity across boundaries.

I honestly think the $lazy proposal is good and impressive, I just feel $box() is more explicit and less mental overhead is require to use it in a number of different ways.

@trueadm
Copy link
Contributor Author

trueadm commented Apr 17, 2024

$lazy isn't designed to solve the problems of $box, that would be a misunderstanding.

The reason we have boxing today, is because there's no way to pass around primitives by reference in JavaScript, unlike other languages. However, that can change, and I've been exploring some ideas around ideas of having something like that in JavaScript. Here's an old one for example https://github.com/rbuckton/proposal-refs. This is something I'm interested in now that I'm a TC39 delegate, so I'd like to see how this one plays out.

@huntabyte
Copy link
Member

$lazy isn't designed to solve the problems of $box, that would be a misunderstanding, they solve entirely different problems.

Copy! Definitely misunderstanding. I took this as an alternative to $box, which is why I expressed discontent with it. In that case I like the proposal / quality of life improvements it brings for these cases.

The reason we have boxing, is because there's no way to pass around primitives by reference in JavaScript, unlike other languages. However, that can change, and I've been exploring some ideas around ideas of having something like that in JavaScript. Here's an old one for example rbuckton/proposal-refs. This is something I'm interested in now that I'm a TC39 delegate, so I'd like to see how this one plays out.

This would be awesome. Is this why you all haven't prescribed a "box" yet, in the hopes that it won't be necessary at all?

@trueadm
Copy link
Contributor Author

trueadm commented Apr 17, 2024

This would be awesome. Is this why you all haven't prescribed a "box" yet, in the hopes that it won't be necessary at all?

100%. If we had refs in JavaScript, and maybe variable decorates, then everyone would get Svelte 5 DX with the addition of passing things through barriers – as part of the language. That's my mission and dream anyway.

@7nik
Copy link
Contributor

7nik commented Apr 17, 2024

How do you tell TS that in

let count = $state(0);
let double = $derived(count * 2);
 
let object = {
    count: $lazy(count, value => count = value),
    double: $lazy(double),
};

double is readonly?
And I struggle to understand what the output in the array example is.

And won't it be annoying to most of the time pass to $lazy basically the same setter? What about indicating that you want to use a default simple setter?
I personally think about

$lazy(value) // simple read-write
$lazy(value, (v) => value = v) // read and custom write
$lazy(value, null) // read-only

@TGlide
Copy link
Member

TGlide commented Apr 17, 2024

How do you tell TS that double is readonly?

Objects can have readonly properties in TS, I assume it would be auto-typed as such.

And I struggle to understand what the output in the array example is.

Same as with an object. With an object it'd be obj.disabled, and with an array, array[0]

And won't it be annoying to most of the time pass to $lazy basically the same setter?

I don't disagree necessarily with that, but think there may be a reason for it

@7nik
Copy link
Contributor

7nik commented Apr 17, 2024

with an array, array[0]

but what is array[0]? object with what props? getter/setter?

Objects can have readonly properties in TS, I assume it would be auto-typed as such.

You can define a prop as readonly, but you cannot mark a value as readonly. And, AFAIK, you cannot preprocess *.svelte.ts files to insert readonly before the prop so it won't work in IDE (vscode). Unless I missed something new in TS.

@TGlide
Copy link
Member

TGlide commented Apr 17, 2024

but what is array[0]? object with what props? getter/setter?

const count = $state(0)
const arr = [$lazy(count, v => count = v)]
console.log(arr[0]) // 0
arr[0] = 1
console.log(arr[0]) // 1

You can define a prop as readonly, but you cannot mark a value as readonly.

What do you mean by value? In this case, obj.disabled is readonly. Meaning you can't do obj.disabled = false in TS. You can modify objects in TS to dynamically mark stuff them as readonly too.

@Rich-Harris
Copy link
Member

I'm opposed to this. I don't think the modest increase in convenience pays for the reduction in clarity, and as mentioned, this doesn't work well with TypeScript:

image

Moreover, I think the cases where you're manually writing getters are relatively few and far between — most of the time you're dealing with $state proxies and classes with $state fields.

It's still too early to know what bottlenecks people will run into. Until we do, additions like this are premature.

Is there a particular reason for this to work with arrays, or is it just because we can? That feels like a really weird use to me

Me too. The only places you use tuples like this are places where you're immediately destructuring them, which seems like it defeats the object.

@abdel-17
Copy link

Moreover, I think the cases where you're manually writing getters are relatively few and far between — most of the time you're dealing with $state proxies and classes with $state fields.

I think you're underestimating the number of times we need to do this. For example, props cannot be passed around easily and for libraries that need to receive state, we need getters.

@mimbrown
Copy link
Contributor

This would be awesome. Is this why you all haven't prescribed a "box" yet, in the hopes that it won't be necessary at all?

100%. If we had refs in JavaScript, and maybe variable decorates, then everyone would get Svelte 5 DX with the addition of passing things through barriers – as part of the language. That's my mission and dream anyway.

Following all the design decisions in Svelte 5 caused me to do my own thinking around what my ideal syntax would be if JS wasn't a barrier, and the syntax I landed on is basically the exact same as the ref proposal (but with readonly/readwrite differentiation). I thought it was a total pipe dream, I had no idea that proposal existed! If there's anything I can do to help push things forward @trueadm...

@mimbrown
Copy link
Contributor

mimbrown commented Apr 18, 2024

And for the record, I think it would make sense if this rune generated getters and setters by default. It makes sense that if you want to have custom get/set logic, you have to write out the getter and setter, and if you want it to be readonly, then you just write the getter. That's normal JS and people are used to it. It's just in the case (which is the most common) that you want getting/setting the property to get/set the underlying state that the current syntax starts to feel really boilerplate-y.

EDIT: should mention that would solve the TS problem above.

@trueadm
Copy link
Contributor Author

trueadm commented Apr 18, 2024

Thank you for all the feedback on this PR. It was always an experiment to see how the syntax felt and we've decided it's not worth proceeding with. There are better ideas out there, and we'll keep looking until we find something that is ultimately right – this just wasn't that.

@trueadm trueadm closed this Apr 18, 2024
@trueadm trueadm deleted the lazy-rune branch April 18, 2024 15:18
@arxpoetica
Copy link
Member

arxpoetica commented Apr 18, 2024

@Rich-Harris

Moreover, I think the cases where you're manually writing getters are relatively few and far between — most of the time you're dealing with $state proxies and classes with $state fields.

It's still too early to know what bottlenecks people will run into. Until we do, additions like this are premature.

It's the blessed way, so everyone will be doing it. I've been actively using Svelte 5, and I deliberately avoid using the functional approach to cross-boundary state because I dislike the get/set pattern so much. I went along with it at first because, oh, who knows why. But having been in the trenches long enough now, I rarely use the functional get/set approach. It ends up adding a lot of ugly boilerplate. Even the wrapped object proxy approach doesn't always solve the get/set ergonomics and can get complicated easily.

I've gravitated toward using classes pretty much all the time now. Much cleaner, svelter.

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

Successfully merging this pull request may close these issues.