Skip to content
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

Reactive blocks run only once per tick, losing changes and allowing fast pace apps to get out of sync #6732

Closed
isaacHagoel opened this issue Sep 15, 2021 · 37 comments
Milestone

Comments

@isaacHagoel
Copy link

Describe the bug

TLDR: Reactive blocks that need to stay up to speed with multiple state changes during the same tick basically break the application in subtle, hard to reason about ways (because things "randomly" go out of sync). This bug breaks the basic guarantee of reactivity.

  • This bug goes all the way back to version 3.0.0.
  • I am giving it high severity because at least for us, it is a huge factor in deciding whether to use Svelte for future projects and I think it will be the same for other teams the encounter these behaviours.

More Info and context (skip to the next section for REPLs):

  • My team is using Svelte in production to build some highly graphic and complex interactive experiences (game like), which sometimes mean rapid state updates and ideally a lot of reactive blocks to keep different pieces separate.
  • This bug happens with stores as well, even though store subs behave differently as they do fire multiple times per tick (which is a good thing). This adds to the confusion and overall feeling of inconsistency (people in my team initially thought stores are causing this issue).
  • Anything async also behaves differently (because it is not in the same tick), it leads to consistent state and potentially introduces infinite loops in a surprising manner (because the code "worked just fine" because of this bug when there was no async behaviour) which adds to the confusion and inconsistency of the DX.
  • Because of how puzzling it is when it happens in a complex app (I change state but it doesn't render), devs in my team were constantly puzzled by why one way of doing something works while another sends the app to hell. We started thinking about reactive blocks as strange foot guns 😢 . Now that I know the root cause we can find (ugly) ways around it by being extra vigilant at all times, but I am sure others will encounter it too.
  • I was told by some on $: reactive shortcut is not current with multiple sync updates to a writeable store #6730 (I thought it is the same issue at first) that this is a built in protection from possible infinite loops. If that's the case this is at minimum a bug in the documentation and in my opinion not a good design decision for the framework. App consistency is more important and this defence mechanism is very limited and confusing anyway. It also means svelte is not well suited for the type of apps that could benefit the most from its full feature-set, performance, small size and elegance (== complex apps built by devs who can easily defend from infinite loops with normal code or the conditionals of the reactive blocks).
  • I know that changing this can break existing apps that rely on it. Could this behaviour be made opt-out via some compiler option or otherwise (Ideally something we can pass in via the rollup config)?
  • I am willing to allocate resources to fixing it if that helps (and if we can get some guidance for where to start from)

Reproduction

Expected behaviour: isSmallerThan10 should be false.
Actual behaviour: isSmallerThan10 stays true, which is out of sync with the app state (and breaks the contract of reactivity)

Logs

N/A

System Info

System:
    OS: macOS 11.5.2
    CPU: (16) x64 Intel(R) Core(TM) i9-9980HK CPU @ 2.40GHz
    Memory: 39.63 GB / 64.00 GB
    Shell: 5.8 - /bin/zsh
  Binaries:
    Node: 12.16.2 - ~/.nvm/versions/node/v12.16.2/bin/node
    Yarn: 1.22.10 - ~/.nvm/versions/node/v12.16.2/bin/yarn
    npm: 7.16.0 - ~/.nvm/versions/node/v12.16.2/bin/npm
  Browsers:
    Brave Browser: 86.1.15.76
    Chrome: 93.0.4577.82
    Firefox: 89.0.2
    Safari: 14.1.2

Severity

blocking all usage of svelte

@rmunn
Copy link
Contributor

rmunn commented Sep 16, 2021

Note that your REPL example can be simplified even further: the same behavior is seen without needing an object property. In other words, when you write let count = {a:1};, you could demonstrate the same behavior even more simply with let count = 1, as follows:

let isSmallerThan10 = true;
let count = 1;
$: if (count) {
  if (count < 10) {
    console.error("smaller", count);
    // this should trigger this reactive block again and enter the "else" but it doesn't
    count = 11; 
  } else {
    console.error("larger", count);
    isSmallerThan10 = false;
  }
}

@rmunn
Copy link
Contributor

rmunn commented Sep 16, 2021

Digging into this, I found the following statement in #4586 (comment):

For performance reasons, $: reactive blocks are batched up and run in the next microtask. This is the expected behavior. This is one of the things that we should talk about when we figure out how and where we want to have a section in the docs that goes into more details about reactivity.

More explanation in #4714 (comment):

The batching of synchronous changes before calling a component's reactive blocks is only really beneficial for performance if the intermediate values are not sent. The purpose of deferring (and, more importantly, combining) the calculations in reactive blocks is to avoid thrashing through a whole bunch of states that you're never going to actually notice in the app. If, for a particular use case, you do need to be informed of each one of those changes, reactive blocks aren't the tool you want to be using, and you should instead use subscriptions or derived stores or some other synchronous mechanism.

Note that the workaround suggested in #4586 (comment) is to use a derived store. In your simplified example, one fix would have simply been to make isSmallerThan10 a reactive declaration:

$: isSmallerThan10 = count.a < 10;

But I suspect that in your real code, there might be more going on and isSmallerThan10 might not be able to be properly declared as a simple reactive declaration like that. In which case making it a derived store would solve your issue:

let count = writable(0);
let isSmallerThan10 = derived(count, $count => $count.a < 10);
// Now use `$count` and `$isSmallerThan10` in the rest of your code

Derived stores are called synchronously whenever their dependencies are updated, rather than batched. Here's an alternate version of isSmallerThan10 that proves that it receives every update of count:

let isSmallerThan10 = derived(count, $count => {
  console.log('count updated to', $count.a);
  return ($count.a < 10);
});

Put that in your example and you'll see these three lines in the console log:

count updated to 1
smaller 1
count updated to 11

You can see that the if ($count.a < 10) branch ran and logged "smaller" with the current value, and then when the count store was updated to 11, isSmallerThan10 received the update.

@rmunn
Copy link
Contributor

rmunn commented Sep 16, 2021

You mentioned wanting guidance as to what to change to change this behavior. I think you might be able to change it with a one-line (well, two-line) fix: remove the if (!update_scheduled) { line in scheduler.ts (and also the closing brace on line 18) and I bet this will cause the $: blocks to become re-entrant.

If that's not enough, then you'll want to look into extract_reactive_declarations() in Component.ts, especially how it sets assignees and dependencies.

I'm not going to make a PR removing if (!update_scheduled), because not only do I prefer the existing behavior, there's already a solution (derived stores).

However, I fully agree that there should be an update to documentation around $: reactive statements to help explain that they are not reentrant: assigning to x inside a reactive block that depends on x will not cause a second update to run. There are several open issues asking for docs updates about reactive blocks: #2778, #4305, #4516, #4586, #4811, and #5520 are some that I found. #5520 in particular is very similar to what confused you: it's caused by how $: will batch updates and "throw away" intermediate updates, whereas store subscriptions will trigger for all updates.

@isaacHagoel
Copy link
Author

@rmunn thanks a lot for the detailed responses.

I know that having isSmallerThan10 updated outside of the problematic reactive block avoids the issue (because it eliminates the need to run the same block twice in the same tick).
I don't think it solves the problem though or how it is different than extracting this logic to another reactive block as you've demonstrated, which effectively means the reactive block doesn't need to run more than once.
As you said, in our real apps there is a bunch of logic going on when deciding what should happen next and it is not always possible to separate the updates out (or to constantly keep this limitation in mind), plus sometimes it would be a chain reaction in which more than one reactive block needs to run more than once in the same tick based on what other blocks did.

Can you please explain further how derived stores solve this? What am I missing?
Also, is there a way for someone to know that their reactive block is going to fail them and they need to use something else?

@rmunn
Copy link
Contributor

rmunn commented Sep 16, 2021

Finally, here's a REPL showing how derived stores can safely update their "parent" store, causing the dependent store to run again. Go to https://svelte.dev/repl/59dc4577ca5a44688535e794b723d8df?version=3.42.6, click the Update button once, and check the console logs to see that the derived store was able to update its parent store and was re-run after the parent store updated. Note three things about this example:

  1. The update() call did not interrupt the execution of the derived store's callback. Instead, the derived store's callback ran to completion, and then the parent store's update triggered the derived store's callback a second time.
  2. There is no protection against infinite loops here. If you change update(x => x+1) to update(x => x+2) your browser tab will freeze up, as the derived store goes into an infinite loop of seeing an odd number, updating the parent store with another odd number, seeing an odd number, updating the parent store..
  3. If you change count.update(x => x+1) to count.set(5), though, it will not cause an infinite loop. That's because when a store is set to the value it already contains, no updates are triggered. Beware, though: if the store contains a Javascript object, it compares by reference, not by deep equality. So if the store had {a: 5} and you run count.set({a: 5}), that will be assigning a new object each time and will enter an infinite loop.

@isaacHagoel
Copy link
Author

@rmunn thanks for the example. I greatly appreciate your effort.
If I understand you correctly, you are suggesting that I structure my application using chains of derived stores instead of reactive blocks. derived stores cannot depend on component local state only on other stores, which means my application shouldn't have local state at all either. It doesn't sound like a reasonable alternative to me.
Even if it was a good alternative I would say that the existing behaviour is highly unexpected and incorrect for the reasons I have stated above.
If my team invests time in adding the ability to opt-out of this optimisation somehow (either via a compiler option or some other way), will the svelte core team be willing to accept this enhancement?

@rmunn
Copy link
Contributor

rmunn commented Sep 16, 2021

I know that having isSmallerThan10 updated outside of the problematic reactive block avoids the issue (because it eliminates the need to run the same block twice in the same tick).

One reason why having isSmallerThan10 in its own reactive block works is because the Svelte compiler re-orders reactive blocks based on their dependencies. So even though the $: isSmallerThan10 = count < 10 line is written above the place where count is reassigned, the Svelte compiler notices the fact that count is reassigned in the large block, and reorders the isSmallerThan10 = count < 10 line below the large block when it compiles its update() function. (Check out the "JS output" tab of your REPL to see this for yourself). Therefore, when the component update runs (once), the count reassignment happens first, and then the isSmallerThan10 check happens later.

sometimes it would be a chain reaction in which more than one reactive block needs to run more than once in the same tick based on what other blocks did.

Can you please explain further how derived stores solve this? What am I missing?

Derived stores solve this because they are not batched, but always run when the parent is updated, even if that's several times. See the example REPL I just posted: the derived store even updates its parent store count, which causes even to receive another update immediately. The "losing changes" effect that you are annoyed by does not happen with derived stores; they always

Note that that does not mean that the derived store always has to update when the parent updates. There's a version of derived stores where the callback takes two parameters, not just one. Then the second parameter is a set function that Svelte will hand you, and it's up to your callback to call set if and when you want to update the derived store's value. Otherwise the derived store will not update. So if I wanted a derived store that didn't modify its parent, but just acted as a filter that only produced even values, I could have written it like this:

let even = derived(count, ($count, set) => {
  if ($count % 2) {
    console.log('Odd, not updating');
  } else {
    console.log('Even, good');
    set($count);
  }
});

Plug that into my REPL in place of the even store that's already there, and you'll see that whenever you click the "Update" button, the callback runs and logs something to the console. But the Even is {$even} text in the HTML output only updates when there's an even value in there, and otherwise it keeps the old value.

Also, is there a way for someone to know that their reactive block is going to fail them and they need to use something else?

I'd say a good rule of thumb would be:

  1. Do you need every update, or just the final value? If you need every update, then change $: into a derived store.
  2. Is there any chance that you'll need to assign to a dependency of the block inside the block? Then you need a derived store, and careful testing to make sure you can't get into an infinite loop.

Also, if your reactive block is calling any functions that update variables it's looking for, that's going to fail. This one is already mentioned in the docs: see the $: total = yPlusAValue(x) example.

@rmunn
Copy link
Contributor

rmunn commented Sep 16, 2021

derived stores cannot depend on component local state only on other stores

Yes, they can. Just use closures. Here's an example: https://svelte.dev/repl/149caba183d84ca8bfaf05ea4ca9d896?version=3.42.6

I would say that the existing behaviour is highly unexpected

Agreed, which is why the documentation needs to be updated to clarify. But there is plenty of existing code that depends on the fact that $: will batch updates, so it would be a breaking change. So it's highly unlikely to change except in Svelte 4.x, if and when that (currently hypothetical) version comes out. Meanwhile, derived stores can do what you need.

Edit: I missed this part of your question:

If my team invests time in adding the ability to opt-out of this optimisation somehow (either via a compiler option or some other way), will the svelte core team be willing to accept this enhancement?

I don't know; I'm not part of the core team and can't speak to their state of mind.

@isaacHagoel
Copy link
Author

@rmunn Thanks for pointing out closures (although they bring stale closures into the mix as well, hurray 😄 ). Maybe it is a valid way forward for us, even if verbose. Will need to think about it some more/ try it out with real code. I would hate to give up reactive blocks altogether.
You are right about it being a breaking change if it is not optional (and default stays the current behaviour). I think it is too late to change the default, I agree with you.

I think that even if the docs get updated people would still trip on it because it is hard to tell whether updates happen in the same tick or not unless you really analyse it and when this behaviour happens it can be quite hard to debug.

Is there a way to ask someone from the core team whether they would be willing to accept such a change (option to avoid the optimisation that's causing this behaviour)?

@rmunn
Copy link
Contributor

rmunn commented Sep 16, 2021

P.S. On the other hand, derived stores can't depend reactively on component-local state, but you might be able to get around that by introducing a special-purpose store that updates itself whenever you need the derived store to fire an update. Something like this:

const updatePlease = writable(0);
const complexStore = derived([a, b, c, d, updatePlease], ($a, $b, $c, $d, _) => {
  // ... code that uses stores a, b, c and d, and also some localStateA and localStateB
  return 'Some value';
});
let localStateA;
let localStateB;

$: { let _a = localStateA; updatePlease.update(x => x+1); }
$: { let _b = localStateB; updatePlease.update(x => x+1); }

The let _a = localStateA line ensures that that block "learns" that localStateA is a dependency, so every time localStateA changes, the $: block re-runs. However, see https://svelte.dev/repl/da05d5f994d0454c8dc95ed8c7311146?version=3.42.6 for an example of where this trick might not always work as desired. The "Update A" and "Update B" buttons work as desired. But if you click the "Update both" button, notice that it doesn't work quite as you might wish. Although the derived store did fire twice, once for the A update and once for the B update, by the time the closure ran, both A and B had been updated so it fires twice with "1 and 1" instead of firing once with "0 and 1". This is kind of the opposite of a stale closure. (What would you call it, a "too-fresh" closure?) At any rate, the cause here is, yet again, the batching of $: statements. Both the let _a and let _b statements ran one after the other in the same tick, and the store updates got queued up for the next tick. So by the time the callback ran twice, both localStateA and localStateB were already updated to their new values.

Basically, you'll have to convert to derived stores every time you need updates that cannot be batched, but you can safely use component-local variables and the updatePlease store trick if you want reactivity, as long as you're aware that the $: calls are batched and you will only get the latest value of those local variables.

Oh, and to answer this question that you posted while I was writing this:

Is there a way to ask someone from the core team whether they would be willing to accept such a change (option to avoid the optimisation that's causing this behaviour)?

According to sveltejs/kit#2100 (comment), the Svelte Discord at https://svelte.dev/chat is probably the right place, specifically in the "contributing" channel.

@isaacHagoel
Copy link
Author

Thanks. I will try my luck on Discord.
tbh, your heroic efforts to make local state work with derived stores reactively makes me think that it would be easier and more robust to convert all local state to stores and use zero reactive blocks, but even that is going to be a whole lot of verbosity and weirdness.

@rmunn
Copy link
Contributor

rmunn commented Sep 16, 2021

You know, I think I've thought of a better rule of thumb for when to use $: and when to use stores. The key concept is to decide, for each bit of state, whether it's part of the presentation or part of the data model.

  • For presentation state, use $: and component-local variables.
  • For data-model state, use stores.

For presentation state, it doesn't matter if you miss an intermediate update, because the human eye isn't that fast anyway. So if you have, say, a live graph that updates its bar-chart columns according to the data in a store, it won't matter if some of the height updates get batched: if they're happening so close to each other, the human eye wouldn't notice the height transition from 3 to 5 if it's immediately followed by another transition from 5 to 8, so having the bar go directly from 3 to 8 will look just the same to the user. (Especially if you're animating the bar height with a tweened store).

For data-model state, it certainly does matter if you miss intermediate updates. Because if you're also calculating the average of all the bars and triggering something (say, writing to a log) when the average is above a certain threshhold, going from 3 to 5 and then from 5 to 8 can have very different effects than going from 3 straight to 8. If going to 5 was enough to pass the threshhold, then 3->5 and 5->8 would write to the log twice, while 3->8 would only write to the log once.

So if the piece of state you're looking at is intended to change the UI, then it's presentation state and you can use $: for it. But if it's intended to have knock-on effects on other pieces of state, then it's data-model state and should be in a store.

@isaacHagoel
Copy link
Author

isaacHagoel commented Sep 16, 2021

For presentation state, it doesn't matter if you miss an intermediate update

The thing is it is not intermediate updates that are missed but all updates besides the first (in my original example)

@bluwy
Copy link
Member

bluwy commented Sep 16, 2021

Looking at Clean example without a store REPL, I don't understand why we would want the reactive block to re-run again, it could easily lead to infinite loops during runtime since there's no robust way to check if the old and new values are equal, e.g. for objects.

I think the current behaviour is feasible and could be documented in the docs for anyone who likes a deeper dive into Svelte's reactivity. But regarding workarounds, while the store technique works, it feels like it would open up risks for infinite loops as well.

Maybe a better workaround is to refactor the code and inverse the control of reactive variables, so instead of if a is foo, b = bar, we change it to b = (if a is foo ? bar : null). That way we can clearly define the dependency graph. I believe any usecases can be refactored this way.

Extra context: We heavily use Svelte in my company project too and we rarely hit this issue much, so I don't think this is a big blocking issue within Svelte.

@isaacHagoel
Copy link
Author

@bluwy sure there are ways to deep compare objects, maybe not super cheap ways (and i wouldn't want them to apply automatically but could be a good idea for another opt in feature). This happens regardless of objects (see the first comment).
We would want the reactive block to run again because the contract of reactive/ declarative programming says that the block describes the app state and in this case it doesn't. The the program correctness (== the most important thing) is just quietly violated.

Infinite loops are usually very easy to notice (because your browser gets stuck), debug and fix, and anyway this is a very partial and un-needed defence because real app stores tend to be connected to servers and then you have asynchrony in the mix and this protection stops protecting you (see my example with timeout), which adds to the confusion.
I am glad it doesn't affect your apps, and agree that for normal apps that change once in a while in straight forward ways based on input from the user (== slowly) this won't be an issue. If you keep pushing the envelop I can almost guarantee that it will cause all sorts of subtle bugs in your apps.
In any case, it is okay if we disagree. All I am asking is to make this optional and backwards compatible.

@bluwy
Copy link
Member

bluwy commented Sep 17, 2021

Thanks for the explanation @isaacHagoel. You're right about reactive programming should ensure program correctness, and there are many ways to achieve that. I think Svelte does ensure that, but in a different way by relying on IoC.

If you do insist on making an optional feature, there are some hurdles I can see:

  1. Deep compare won't work robustly in all cases, which could lead to either infinite loops or program incorrectness.
  2. Infinite loops needs to be detected in some way, we can't have the browser freezing as heuristic, that wouldn't be ideal for prod.
  3. Maybe immutable: true could help with the comparison, but that could be an undesirable side-effect.

IMO this path would as well cause some subtle bugs, and is just one of the many ways to ensure program correctness. So from my POV, you could try that if you want, but it isn't a silver bullet too.

Also: The maintainers have not forgotten about this! We're still discussing asynchronously the best path forward.

@dummdidumm
Copy link
Member

There's a related RFC from another community member about this: sveltejs/rfcs#40

@isaacHagoel
Copy link
Author

thanks @dummdidumm . I will read it in details.
@bluwy you are right that comparing objects comes with its own set of complexities. Just to clarify, that's not what I am suggesting/ requesting here. I want to be able to opt out from infinite loops protection altogether and take full responsibility for that in my code, while Svelte is responsible for correctness.

@benmccann
Copy link
Member

I think of reactive variables like cells in a spreadsheet. In a spreadsheet, you can't have a cell reference itself. The example in this issue seems like problematic code to write because the reactive block is both reading and writing the same variable value and it's unclear to me exactly how that should work. You could write that code in a much clearer way with far fewer lines and without the use of any stores like this: https://svelte.dev/repl/909e0ee9b2484dc3b9618dea80f82c86?version=3.42.6

@isaacHagoel
Copy link
Author

@benmccann this is a toy example. I agree with you that in this case the code could have been different and better. Not so much in real life scenarios that we have. I could make an example that uses this kind of mechanism for a few reactive blocks to communicate with each other via state changes. The problem here is not the self referencing nature of the code (which is btw, legit - recursion is a very common pattern). The problem is that the block has only one chance to run per tick and that it does so silently without providing any warning or indication.

@benmccann
Copy link
Member

Yeah, I definitely get that it's a toy example. But I think what I suggested would apply to any more complex example as well.

Recursion is fine in JavaScript, but not a spreadsheet. I'm not saying to avoid recursion, but that I personally wouldn't choose recursive reactivity, and code is clearer if recursion is done outside the reactive code.

E.g. this code is far nicer:

let y = 10;
$: x = fibonacci(y);

Whereas this code is not as nice:

let y = 10;
let x = y;
$: = {
  if (y > 1) {
     y -= 1;
     x = x * y;
  }
}

I agree with your point that it should at least be documented. But personally I'd try to avoid that code and think a lint rule warning it's ugly might be even more useful.

@isaacHagoel
Copy link
Author

isaacHagoel commented Sep 19, 2021 via email

@benmccann
Copy link
Member

Yeah, an example would help especially if it demonstrates a real use case

@isaacHagoel
Copy link
Author

@benmccann there is a discussion happening on #6730 so I am putting a new example there. I still used a single reactive block because I felt it is already getting too big even though I simplified it down as much as I could. At its core it represents a real use case that broke for us.

@isaacHagoel
Copy link
Author

@rmunn based on your advice, I was playing with derived stores a bit.
I noticed that svelte optimizes them away if the store value is not included in the markup (like it would with any state). It does it even when the internal block affects some other store that does get rendered.
If a derived store is supposed to replace a reactive block its own value should never be rendered 😢
Do you know if there is a way to tell svelte not to do this optimisation and still keep the code?

See this REPL. Uncomment line 18 and it starts working.
Thanks

@rmunn
Copy link
Contributor

rmunn commented Sep 24, 2021

It's not that the store is optimized away if you don't include the store in the markup, it's that derived stores don't run their callback when nothing has subscribed to them. Actually, derived stores don't even set up their own subscriptions until someone subscribes to the derived store. This is so that derived stores work similarly to readable stores (in fact, derived stores are implemented as return readable(initial_value, (set) => { /* do setup here */ } with the "do setup here" code being what subscribes to stores and so on), and I don't think that optimization is ever going to change. That optimization is what allows you to set up a whole chain of derived stores without doing any work until it's actually needed.

The good news for you is that all you have to do is set up a whole chain of derived stores, and subscribing to the last store will cause the whole chain to activate. I.e.,

import { writable, derived } from 'svelte/store'

const a = writable(0);
const aPlus1 = derived(a, x => x+1);
const aPlus2 = derived(aPlus1, x => x+1);
const aPlus3 = derived(aPlus2, x => x+1);
const aPlus4 = derived(aPlus3, x => x+1);
const aPlus5 = derived(aPlus4, x => x+1);
aPlus5.subscribe(x => {
  console.log('a+5 = ', x);  // Will log 'a + 5 = 5'
});
$a = 13;  // Will log 'a + 5 = 18'

Here's a REPL to prove that to you, along with some extra console logging at every step so you can see that each step is activating, not just the final step. https://svelte.dev/repl/7373bbf6d17a44d88676544ae3697aee?version=3.43.0

@rmunn
Copy link
Contributor

rmunn commented Sep 24, 2021

Also, looking at the Javascript code for your REPL, even without uncommenting line 18 the JS code for creating the bla store still existed. Which means that it wasn't optimized away, it just wasn't running until something subscribed to it.

Therefore, you don't necessarily need to subscribe to a derived store in the same component that creates it. If the derived store is being exported from the component as a module export, or being saved in component context with setContext, or in some way accessible outside the component, then you can subscribe to it from somewhere else and the derived store will kick into action at the moment it's subscribed to.

@rmunn
Copy link
Contributor

rmunn commented Sep 24, 2021

Here's an updated version of your REPL that shows that simply subscribing to the derived store is enough to make it run, and you don't actually need it in the markup: https://svelte.dev/repl/66c6820032614a66be7d739abc70d7be?version=3.42.6

But look at the console log of that REPL and notice that the phrase "Derived callback fired" is only printed once no matter how many times you click the Update button. That's because of another optimization that you'll need to look out for if you're using derived stores to trigger side effects like writing to other stores: the value returned from a derived store's callback is used to set the value of the derived store, using set(). Remember that setting a store value to the value it already had does not run subscriptions; subscriptions are only run when a store's value changes. And as written, the bla store your REPL does not return anything, meaning the value stored in bla is undefined. The first time anyone subscribes to bla, their subscription will be called synchronously with the store's current value, which is undefined. But when bla's callback runs again due to count updating, its callback returns undefined, so the bla store's value is getting set to undefined again and is therefore not running subscriptions.

You can see that in action by editing the REPL I just linked in this comment and adding return $count; to the end of the bla callback. Then click on the update button a few times and notice that now, bla's subscription is running every time. I find that the simplest way to make sure that your derived store callback runs every time (and therefore triggers any side effects that you want to run) is to have it return the value of its parent store. Because that value will be different every time, otherwise the parent store would not have called its subscribers.

However, for derived stores that derive from two or more parent stores, you'd have to do something like return [value1, value2]; which is inefficient as it would create a new array each time, requiring the end user's browser to do a lot of garbage collection. Instead, if you want to return a value that changes every time but doesn't require garbage collection, I'd use a Boolean variable, something like this:

let complexStoreToggle = false;
const complexStore = derived([parentA, parentB, parentC, parentD], ([a, b, c, d] => {
  console.log('Doing some side effect with a, b, c and/or d');
  complexStoreToggle = ! complexStoreToggle;
  return complexStoreToggle;
});

Now the value of complexStore changes every time any of its parent stores are changed, and therefore anyone subscribing to complexStore will get their subscription fired every time.

I hope that makes sense. If anything I've said doesn't make sense, please ask and I'll try to explain. It might take a few days as I have a busy weekend coming up, but I'll get back to you next week if I can't do so sooner.

@rmunn
Copy link
Contributor

rmunn commented Sep 24, 2021

@arackaf - You might be interested in the above comments as well, since it's likely that some of your issues with reactive statements might also be solved by converting them to derived stores.

@isaacHagoel
Copy link
Author

@rmunn thanks. your insight about the need to subscribe in order to activate the store really helped!
so i can make something like this:
https://svelte.dev/repl/8b9cb147205f4f4bb29045ef2e99cd3c?version=3.43.0
For some reason in this case the lack of a return value doesn't seem to be an issue.
I need to play with this idea and see if it can do everything i need it to do. If yes, i will make a library out of it.

@rmunn
Copy link
Contributor

rmunn commented Sep 24, 2021

The lack of a return value in effect isn't hurting you now, but it's going to hurt you later. Because once effect() returns, that const s you declare in effect() is not referenced by anything, so it becomes eligible to be garbage-collected. As does its subscription function. And so at some point, its side effect is going to just stop working, because both s and the side-effecting function subscribed to it were garbage collected.

And if it isn't garbage collected, then you have the opposite problem, a memory leak, because you never saved the return value of the subscribe call so you have no way to unsubscribe. Better to turn s.subscribe(() => {}); into return s.subscribe(() => {});, and then call the unsubscribe function at the appropriate time (in $onDestroy(), or in the cleanup of the use: action you're using, or whatever).

@dummdidumm
Copy link
Member

dummdidumm commented Sep 24, 2021

This may got a little lost in the other thread, but here's another approach by Rich for a simulated useEffect which does not need to be used with stores: https://svelte.dev/repl/0c9cd8c29c5043eea89bd9c6eb4f279a?version=3.42.6

@isaacHagoel
Copy link
Author

@rmunn
garbage collection doesn't seem to be an issue. I verified it by initiating it manually from the dev tools and it doesn't break the app (luckily, otherwise there would have been something terribly wrong with the gc algorithm).
Rich suggested a solution to potential memory leaks using onDestroy within effect. It blows my mind a bit because I didn't know lifecycles can be used outside of components. I guess it works because after compilation there is only the component.

@dummdidumm
Thanks. I did miss it somehow. I think the problem with using ticks everywhere (because afterUpdate won't run the second time in the same tick, right?) is that it introduces a lot of asynchrony which can get extremely confusing an hard to track. I might be wrong about this (please tell me if you disagree). I will give it more thought. But this is what intuition (== experience) tells me. With the derived stores everything has the most up to date value instantly.
It does make me wonder if I can use a similar approach so that the effect can get a mix of stores and non stores, track the non stores "manually" like Rich does in his example and the stores using the derived store like my example does. Is there any lifecycle that will run more than once in the same tick?

Thank you both for taking the time to interact with me. I do not take it for granted.

@isaacHagoel
Copy link
Author

@rmunn @dummdidumm I take it back about ticks. I have been playing a bit and it doesn't seem like they cause discrepancies. It looks like $: tick().then(() => { is equivalent to both of these implementations of effect for all practical purposes. unless i am still missing something. Commenting on the other thread as well to keep it one conversation.

@rmunn
Copy link
Contributor

rmunn commented Sep 25, 2021

It blows my mind a bit because I didn't know lifecycles can be used outside of components. I guess it works because after compilation there is only the component.

The docs say that onDestroy "Schedules a callback to run immediately before the component is unmounted." So you can put it inside a helper function and that's okay, because when that helper function is being run, it's always being run by a component, and it will add that callback to that component's onDestroy list.

@dummdidumm
Copy link
Member

This will be fixed in Svelte 5. The runtime is more consistent now with listening to updates. To not break backwards-compatibility, the $:-behavior of running only once is preserved, but using $derived and $effect instead will yield the correct results.

@Proziam
Copy link

Proziam commented Mar 28, 2024

@dummdidumm Is this issue related to goto() failing when conditional?

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