-
-
Notifications
You must be signed in to change notification settings - Fork 4.2k
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
chore: add $derived.call rune #10240
Conversation
🦋 Changeset detectedLatest commit: 97261bf The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
I'm not convinced this is the right API. Firstly, consider the case where you want to reuse the comparison logic in multiple places. You'd have to do this sort of thing... <script>
let one = $state([1, 1, 1]);
let two = $state([2, 2, 2]);
function equal(a, b) {
return JSON.stringify(a) === JSON.stringify(b);
}
let one_reversed = $derived.fn((prev) => {
const reversed = one.toReversed();
if (equal(reversed, prev)) return prev;
return reversed;
});
let two_reversed = $derived.fn((prev) => {
const reversed = two.toReversed();
if (equal(reversed, prev)) return prev;
return reversed;
});
</script> ...when this would be much easier: <script>
let one = $state([1, 1, 1]);
let two = $state([2, 2, 2]);
function equal(a, b) {
return JSON.stringify(a) === JSON.stringify(b);
}
let one_reversed = $derived(one.toReversed(), { equal });
let two_reversed = $derived(two.toReversed(), { equal });
</script> Secondly, the fact that Thirdly, it's honestly a bit weird that you signal 'the values are equal' by returning Fourthly, if equality checking is useful, it's useful for I'm still not wholly convinced that we need this, but if we really must then I really think we should work on the naming. Elsewhere we prefer to use full words where possible. I'm not sure what a better name would be — will have a think — but I hope we can avoid |
I also think it's worth noting that |
I mean I figure you'd end up use curried functions for those cases where you would be straight duplicating the logic. i.e. <script>
let one = $state([1, 1, 1]);
let two = $state([2, 2, 2]);
function equal(a, b) {
return JSON.stringify(a) === JSON.stringify(b);
}
function createReverser(state) {
return (prev) => {
const reversed = state.toReversed();
if (equal(reversed, prev)) return prev;
return reversed;
};
}
let one_reversed = $derived.fn(createReverser(one));
let two_reversed = $derived.fn(createReverser(two));
</script>
This is definitely more succinct, though it's not immediately obvious to me what is actually happening here. I take it I do also agree all things considered, the desire to avoid IIFE's is very much a convenience/preference thing. That said there are definitely people with stronger opinions than my own, and using this syntax to provide the |
example from discord for another potential use case. |
You'd need to use |
...which is a level of complexity that isn't really in keeping with Svelte. It's also broken — updating
Not sure I understand. You can just do this? -const prev = $derived.fn(previousState(() => value))
+const prev = $derived(previousState(() => value)()) So there's two things going on here:
|
I don't think that this kind of mistake in usage hints at a fatal flaw in API design, it's just how the system works. Having access to the previous value is a nice bonus to enable reducer patterns (the equality thing is an accidental side effect of this for me) |
I'm not sure why this is a non-starter TBH. Other than it initially being If I'm using In many ways this is kind of like how you can set state in React with knowledge of the previous values ( <script>
const { markdown, submitMarkdown } = $props();
let is_editing = $state(false);
const initial_value = { markdown: '' };
const state = $derived.reduce(state => {
let new_state = $state({ markdown: is_editing ? state.markdown : markdown });
return new_state;
}, initial_value);
</script>
<MarkdownEditor
bind:markdown={state.markdown}
onEdit={() => is_editing = true}
onReadOnly={() => is_editing = false}
onSave={() => submitMarkdown(state.markdown)}
/> Imagine we don't control It's tempting to try and do something like this: <script>
const { markdown, submitMarkdown } = $props();
let is_editing = $state(false);
let local_state = $state({ markdown });
const state = $derived(is_editing ? local_state : { markdown });
</script> However, when we switch modes back and forth, the I'm eagerly trying to push people away from this pattern: https://discord.com/channels/457912077277855764/1153350350158450758/1198567900450144326 Another approachSo I thought to myself, how can we deal with this from another angle? Taking my above example, maybe it makes more sense to position it from an another angle. What if we could derive values from within state? <script>
const { markdown, submitMarkdown } = $props();
let is_editing = $state(false);
let state = $state({ markdown }, state => ({ markdown: is_editing ? state.markdown : markdown }));
</script> |
Let's at least keep this issue open for now, since the discussion is still ongoing.
I outlined a few points above, but consider also the impact on types: As soon as I think the reducer idea is interesting. Initially it seemed off because the reducer pattern is
For posterity: // data comes from sveltekit load function
const { data, form } = $props()
let markdown = $state(data.markdown)
$effect(() => {
markdown = data.markdown
}) If we were to use We need to fully articulate that problem though. If it's 'I want the view of some state to be this value while some condition (e.g. But if it's 'I want to have some locally edited state that is replaced whenever there's new stuff from the server' — which better describes the above snippet — then it seems to me that effects are the right tool for the job, because fundamentally what you're trying to do is have a piece of state that represents the most recent of <data from server> and <local edits>, and effects are how you incorporate temporal concepts like recency. I don't think a reducer pattern helps us here. |
One of things that I'm worried about is a future in where we have the forking of state. For example, a pending state tree for async/suspenseful transitioning UI that has not yet been applied. In a fork that is still pending, we cannot run effects – as they might mutate the DOM or trigger some other irreversible side-effect. If there is some effect that is responsible for syncing the state tree then that won't happen anymore. This is important, as an incoming prop may have changed, causing the pending state to change in some part. If that no longer happens then you have a glitchy state tree. So moving as much of that to derived state is always preferable. |
Ha - I was also thinking about that stuff, but from a different angle. Namely that the smaller the API surface area today, the more freedom we'll have in future. Part of the reason I'm so resistant to new API in general is that I've often been painted into a corner by convenience APIs that weren't truly necessary. Big picture: we're on the same page, just not fully aligned on all the details |
I don't really understand this "adding the runtime overhead of tracking whether signals were read while creating the deriver." If EDIT: I take that back, I understand that there's multiple ways that signals could be read while creating a deriver: const myDerived = $derived.fn(higherOrderFunction());
const myDerived = $derived.fn(someCondition ? functionOne : functionTwo); But I don't think it's cumbersome to explain that "only signals read inside the passed function will be tracked." I stand by that many people already think in those terms. The ability to pass the function itself opens up possibilities like when you want to do something with feature detection, where you want the ability to pass completely different logic based on some check that only needs to happen once, like: const myDerived = $derived.fn(browserSupportsFeatureX ? functionOne : functionTwo); |
The example given by @dummdidumm was mine |
I second the naming |
|
Does it have to be a separate API though, at least if there are no additional changes other than it being a function? Can't it just be overloaded and have the type checked in the compilation step? |
I was making that point in response to @Not-Jayden's comment upthread...
...which wouldn't work. The very first example of someone using That's why I'd be slightly more open to a version of this where you can only pass a function expression: const ok = $derived.whatever(() => {...});
const not_ok = $derived.whatever(somefn); It would prevent that category of bugs, at the cost of the 'uncanny valley' phenomenon.
Would love it if that was possible, but I don't think it is. Consider: const filter_function = $derived(filter_functions[current_filter]); How would you type that such that |
This was my first suggestion #9984, which was closed in favor of #9968
In case of overloading, the correct return should be the result of calling filter_function const filter_result = $derived(filter_functions[current_filter]);
const filter_function = $derived(() => filter_functions[current_filter]); |
We spent a healthy chunk of time discussing this issue on Friday, and reached the following conclusions:
|
I know this is probably too little too late to flag this, but I intentionally avoided suggesting e.g. in theory you would expect $derived.call(null, count * 2); to be equivalent to: $derived(count * 2); While I don't think its a huge deal, it is breaking JS conventions a little. |
IMO the above is a nonissue; |
Furthermore, it’s been mentioned before that runes are meant to be similar to the import() keyword. So, the conventional function rules don’t apply:
Blatantly plagiarised from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import#syntax |
How about $derived.fromFunction or $derived.fromMethod, or something similar? |
good point @Not-Jayden — I've opened #10334 with a suggestion |
* chore: add $derived.fn rune * fix strange bug * update types * remove prev stuff * regenerate types * $derived.fn -> $derived.call * docs * regenerate types * get rid of $$derived * tighten up validation etc * fix tests --------- Co-authored-by: Rich Harris <rich.harris@vercel.com>
Rich here — after extensive discussion we decided to leave out the previous value from the signature, and to call it
$derived.call(fn)
rather than$derived.fn(fn)
. Original comment follows:$derived.fn
Sometimes you need to create complex derivations which don't fit inside a short expression. In this case, you can resort to
$derived.fn
which accepts a function as its argument and returns its value.$derived.fn
passes the previous derived value as a single argument to the derived function. This can be useful for whenyou might want to compare the latest derived value with the previous derived value. Furthermore, you might want to pluck out specific properties of derived state to use in the new derived state given a certain condition – which can be useful when dealing with more complex state machines.