Skip to content

feat: $derived.by get current value as first argument #13250

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 2 commits into from

Conversation

paoloricciuti
Copy link
Member

@paoloricciuti paoloricciuti commented Sep 15, 2024

Svelte 5 rewrite

Closes #13249

This is an implementation but it needs discussion for sure for the following reasons:

  1. it basically reverts chore: avoid creating unnecessary thunks #9841 ...the optimisation is cool but considering we are using functions that rely on argument.length (namely the value returned from $.prop) it's kinda risky because, like in this case, if we decide in the future to pass something in to the function that uses them removing the thunk will introduce a bug...this time luckily it was caught by two tests...but that's just two tests who knows in the future. The alternative should be a big refactor to always use create_derived and check in there if the binding is a prop or a reactive import. I went this route because it feels a micro optimisation relative to the difficult to debug bugs we could be getting.
  2. as pointed out in $derived.by destructor support (Svelte 5) #13249 (comment) this implementation pass null as the first value...but this means that if the value is actually null you'd have no way of knowing if this the initialisation or the actual value. We could either pass a second argument to tell if it's the initialisation or pass a symbol instead of null. Both are not very appealing to me.
  3. the code that i wrote to pass the argument
var args = [];
if (reaction.f & DERIVED) {
    args.push(/**@type {Derived}*/ (reaction).v);
}
var result = /** @type {Function} */ (0, reaction.fn)(...args);

could be simplified with just

var result = /** @type {Function} */ (0, reaction.fn)(reaction.v);

because .v is only defined for deriveds but i wanted to avoid the situation where in the future we change that and this introduces a bug (or if we read arguments.length somewhere else).
5. typescript is also not super-good here...if you actually get the value you need to type it or it will be unknown (i wasn't able to find a way to make TS infer that from the return value) and you also need to type it as YourType | null or it will complain.
6. Do we actually want this solution or do we prefer to go for something else?

No test for the moment but i'll add one when we reach a consensus.

Please note that the Svelte codebase is currently being rewritten for Svelte 5. Changes should target Svelte 5, which lives on the default branch (main).

If your PR concerns Svelte 4 (including updates to svelte.dev.docs), please ensure the base branch is svelte-4 and not main.

Before submitting the PR, please make sure you do the following

  • It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs
  • Prefix your PR title with feat:, fix:, chore:, or docs:.
  • This message body should clearly illustrate what problems it solves.
  • Ideally, include a test that fails without this PR but passes with it.

Tests and linting

  • Run the tests with pnpm test and lint the project with pnpm lint

Copy link

changeset-bot bot commented Sep 15, 2024

🦋 Changeset detected

Latest commit: 090f06a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
svelte Patch

Not sure what this means? Click here to learn what changesets are.

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

@ryanatkn
Copy link
Contributor

ryanatkn commented Sep 15, 2024

On 2), if it instead initializes to undefined it'd have the same expectations and caveats that normal JS has with uninitialized variables, where you can't tell if a variable has been assigned to undefined or is uninitialized. Initializing to null seems surprising/conflicting and the symbol idea seems unnecessary.

@timephy
Copy link

timephy commented Sep 15, 2024

I am of the same opinion, a simple let x is undefined before its value is set.

null is also more often used as "None" than undefined (more often used by the user), therefore it might make more sense for undefined to be the sentinel for "not-initialized"...

@Rich-Harris
Copy link
Member

Some thoughts on why we shouldn't do this: #13249 (comment)

@Azarattum
Copy link
Contributor

Azarattum commented Sep 16, 2024

I'd say having the previous value in $derived.by would be extremely useful. I had cases where I needed it and intuitively reached for the argument only to find out it is not there. It is especially useful in cases where you would want to "skip" the derivation (e.g. state doesn't meet some constraints) and keep the previous value instead.

For destructuring purposes I agree with @Rich-Harris. That does feel like an anti-pattern and should be discouraged (there is $effect for that). Though an escape hatch for edge cases is better than nothing at all.

Also, accessing the previous value is trivial in Svelte 4 with just self-reference. And it returns undefined on the first iteration (I'm all for undefined as the first value, btw).

$: double = (console.log(double), count * 2); 

Migrating the same code to Svelte 5 gives:

A derived value cannot reference itself recursively

Which is bad for backwards compatibility.

@Azarattum
Copy link
Contributor

When writing the comment above, I've noticed that auto-migration generates invalid code. When trying to process

$: double = (console.log(double), count * 2); 

it outputs

let double = ($derived(console.log(double), count * 2); 

which is obviously wrong

Error compiling App.svelte
Unexpected token

REPL. We might need an issue for that (if there isn't already one).

@paoloricciuti
Copy link
Member Author

I'd say having the previous value in $derived.by would be extremely useful. I had cases where I needed it and intuitively reached for the argument only to find out it is not there. It is especially useful in cases where you would want to "skip" the derivation (e.g. state doesn't meet some constraints) and keep the previous value instead.

While I agree that it could be useful if you read Rich comment is pretty trivial to do already by storing the value in a non state variable.

When writing the comment above, I've noticed that auto-migration generates invalid code. When trying to process

$: double = (console.log(double), count * 2); 

it outputs

let double = ($derived(console.log(double), count * 2); 

which is obviously wrong

Error compiling App.svelte
Unexpected token

REPL. We might need an issue for that (if there isn't already one).

Yeah I think we need one, if you can open one that would be great

@Azarattum
Copy link
Contributor

do already by storing the value in a non state variable

We can, but it feels like a hack... Having a function parameter is much more elegant and intuitive. It also nicely encapsulates the derivation logic without having to create additional variables in a scope where other logic might live in. Also, if I understand that correctly, there isn't any memory/performance overhead since Svelte internal store this value anyway.

Yeah I think we need one, if you can open one that would be great

Opened an issue #13278.

@Azarattum
Copy link
Contributor

Here are some cases where it might be useful:

  • Any kind of computation that involves accumulation: REPL
  • Any kind of computation that needs constraints (think tracking mouse position but only within element boundaries): REPL

In both cases effect is needlessly complicated and doesn't communicate developer's intent well. I would argue that these are pretty common types of problems in UI development (I personally encountered variations of these cases quite many times)

@paoloricciuti
Copy link
Member Author

Here are some cases where it might be useful:

  • Any kind of computation that involves accumulation: REPL
  • Any kind of computation that needs constraints (think tracking mouse position but only within element boundaries): REPL

In both cases effect is needlessly complicated and doesn't communicate developer's intent well. I would argue that these are pretty common types of problems in UI development (I personally encountered variations of these cases quite many times)

Quoting Rich here

In the second case, while there might be some very rare cases where it's valid to treat the previous value, it's likely a sign that things need to be rethought a bit. What if you need the previous-but-one value? And so on. Providing it directly to the derivation might be convenient in a handful of cases but it makes it impossible to infer the correct type, and — again — it actively promotes something we should likely discourage.

It's possible that as the framework evolves I'll need to update this stance and we should consider passing in the previous value, but that's a bridge to cross then — for now it feels reckless.

Those might be valid cases but there might be cases where you want all the previous values too...what to do then? Should svelte pass all the values? Considering the TS problem and the fact that you can easily do that yourself without an effect and with minimal code that also gives you flexibility of storing every previous value I lean towards we shouldn't rush this and add it only if we find that not doing it is way worse than doing it.

@Azarattum
Copy link
Contributor

What if you need the previous-but-one value? And so on.

I think this is irrelevant, since we already have the previous value. So, we can let a user decide what to do with it or whether to use it at all. I can see why adding anything beyond that (e.g. previous-but-one) wouldn't be feasible, because it must be implemented separately. But this is essentially a free feature that wouldn't even complicate the API (if you omit the parameter it's like it's never been there).

Also from @Rich-Harris comment:

future ambitions to add forking to the framework

By the way, I might be not seeing the full picture here. Has there been any discussion around forking already? Would adding this API complicate its implementation? In that case, I can see why it might not be free. Though, as it stands right now, I see no reason not to add it.

@paoloricciuti
Copy link
Member Author

But this is essentially a free feature that wouldn't even complicate the API (if you omit the parameter it's like it's never been there).

The point is that there's no such thing as a free feature. Once you have this API you also have to leave with the consequences of it and the forking example is a premier candidate of this concept. Granted that I don't have personally idea of how this feature will affect forking if Rich mentioned it is likely that will have an effect. And that could be true also for other features that one might want to introduce (maybe we realize that we want something passed to $derived.by in the future and if we merge this we will have to navigate around the fact that we are already passing the current value as the first argument).

This is not to say we shouldn't add any feature for the fear of stopping something in the future but since is this case is very easy to have an alternative it might be worth.

@Azarattum
Copy link
Contributor

Hmm, I see... So, is it settled that we won't have it or is it still open to discussion? Do we need more opinions here?

@dummdidumm
Copy link
Member

still open to discussion, just that it won't happen right now

@trueadm
Copy link
Contributor

trueadm commented Sep 18, 2024

I've been thinking about this more, and kind of like the idea of making $derived.by accept a second optional argument, that being the initial value. If you do, then you'll get the previous value provided into the the derived function, if you don't then you won't get anything. This solves multiple issues:

  • It allows you to easily determine if the previous value is not set, as you can decide on null or undefined.
  • Alternatively, it allows you to avoid that altogether by passing in a value type that is the default. That can be the initial value like $state or an empty array or maybe an object where the properties are defaults.
  • If you pass in the initial value that is the same that gets captured in the derived then they will be === and easy to capture without odd hacks or using some variable outside (which won't scale with async forks).

Plus, we can type the $derived.by function using an overloaded form that states if we provide a second argument then the function is provided that value. So type safety is taken care of nicely.

Here's an example of how the previous value can be really useful but this hacky way will cause countless issues in the future. It would essentially allow us to create $state.link without needing a new rune!

However, one common complaint with this pattern is "how do I know the thing I really care about is update?". i.e. if you had:

let local = $derived.by((prev) => {
  if (prev !== null && draft) {
    return prev;
  }
  const local_state = $state({ count });

  prev = local_state;
  return local_state;
}, null);

Then when draft changes, this derived will re-fire, but what if I only care about if count changes, because maybe I need to do some more work with that?

Further thoughts

That has got me thinking about change detection mechanics. Maybe we make it explicit by having a third argument that is for fine-grain change detection:

let local = $derived.by((prev, [ count_changed ]) => {
  if (prev !== null && (draft || !count_changed)) {
    return prev;
  }
  const local_state = $state({ count });

  prev = local_state;
  return local_state;
}, null, [ count ]);

Now we know when specific things change (internally we call this dirty) and in this case we know when count has changed and thus we can appropriately react to that without needing to worry about if draft changes even though they're both dependencies.

Food for thought, but I think this inherently solves many of the issues $state.link had whilst not expanding on our rune API surface (other than upgrading $derived.by to be more useful).

@Azarattum
Copy link
Contributor

It allows you to easily determine if the previous value is not set, as you can decide on null or undefined.

@trueadm, do you suggest to use a compiler to differentiate between an explicit undefined $derived.by((x) => x, undefined) and the implicit one (no argument) $derived.by((x) => x)?

Other than that, the second argument approach is fine (better than nothing at all). Though I'd prefer $derived.by((x = "initial value") => x) syntax to $derived.by((x) => x, "initial value") which we already get if we let it be undefined by default. This way libraries that provide complex derivation functions can encapsulate the initial value (e.g. $derived.by(complexDerivationFrom(x))). The alternative would be a JSDoc that tells user to use the function with a specific initial value $derived.by(complexDerivationFrom(x), 0) which is fragile or something like this $derived.by(...complexDerivationFrom(x)) which is ugly.

Maybe we make it explicit by having a third argument that is for fine-grain change detection

Couldn't that be achieved with untrack? I think Svelte 5 has been pushing against explicit dependency arrays so far. Feels weird to have it here.

I think this inherently solves many of the issues $state.link had whilst not expanding on our rune API surface

Solving $state.link with $derived.by is an awesome idea! It would also benefit from the encapsulation I've described above, so that you can do const local = $derived.by(link(count)) (where link is the user defined helper function similar to what you have described) without defining a sibling prev variable out of nowhere.

@ryanatkn
Copy link
Contributor

ryanatkn commented Sep 19, 2024

For comparison, here's a userland higher order function that provides the previous value. Something similar could be implemented for arbitrary history, different handling of initial values, cache busting behaviors, etc. Syntax isn't as nice as a builtin of course - REPL

const doubled = $derived.by(with_previous((prev) => {
	// would use `prev` here, the example just logs and outputs to the DOM
	return count * 2;
}, null));

function with_previous(cb, initial) {
	let prev = initial;
	return () => (prev = cb(prev));
};

You could also call onDestroy in the helper, and you could avoid boilerplate with a hardcoded destroy interface like value.destroy().

@trueadm
Copy link
Contributor

trueadm commented Sep 19, 2024

@trueadm, do you suggest to use a compiler to differentiate between an explicit undefined $derived.by((x) => x, undefined) and the implicit one (no argument) $derived.by((x) => x)?

It can be done at runtime.

Other than that, the second argument approach is fine (better than nothing at all).

I really dislike treating undefined as the initial value. It means that you can never have undefined as a genuine value which is super common with chaining foo?.bar?.thing will often be undefined and then you just get into a whole mess. Not to mention the typings are also not as clean.

Couldn't that be achieved with untrack? I think Svelte 5 has been pushing against explicit dependency arrays so far. Feels weird to have it here.

To be clear here, this isn't a dependency array. It's about as far from a dependency array as possible. It's more of a way of letting the derived know of change detection for specific things.

@Rich-Harris
Copy link
Member

@ryanatkn this is brilliant, and cements my position that we don't need this in the framework, at least not any time soon. Your REPL shows a workaround for the fact that you can't set state from a derived, but you don't actually need it — you can use the previous value in the returned value.

In the interests of making progress towards a release, I think we should close this PR.

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

Successfully merging this pull request may close these issues.

$derived.by destructor support (Svelte 5)
7 participants