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 shortcut is not current with multiple sync updates to a writeable store #6730

Closed
arackaf opened this issue Sep 15, 2021 · 111 comments
Milestone

Comments

@arackaf
Copy link
Contributor

arackaf commented Sep 15, 2021

Describe the bug

$: does not reliably fire with a writeable store, while store.subscribe does.

It seems like all initial writeable store updates within the same tick are gathered, with only the last, most recent one firing for the $: reactive handler, usually. However, it seems like it can be possible for the $: handler to fire pre-maturely, when there are still other state updates to happen within the same tick, which wind up going un-reported.

See repro link below.

Reproduction

https://svelte.dev/repl/57dbee8266d840d09611daebee226b91?version=3.42.5

I get the following in the dev console. Note that the {loading: "true"} update is not reported from the $: handler, with the XXX, until the NEXT update happens. This results in the wrong state being reported, initially.

image

Note that moving the call to sync up, fixes this. It seems like Svelte's analysis of the code is missing an odd edge case.

https://svelte.dev/repl/e88c70d6fd224b0d84656f83afd7e63c?version=3.42.5

Logs

N/A

System Info

REPL

Severity

blocking an upgrade

@arackaf arackaf changed the title $: reactive shortcut is not working with a writeable store $: reactive shortcut is not current with multiple sync updates to a writeable store Sep 15, 2021
@isaacHagoel
Copy link

We've hit this one as well. I was about to create an issue and then I saw this.
Here is a very simple REPL reproducing the issue (might be a bit simpler than the one provided).
The variable isSmallerThan10 should be false but it is true.

This seems to exist in older versions of Svelte down to 3.0.0 as far as I can tell. So severity should be higher than "blocking an upgrade".
@arackaf I downgraded your example to v3.0.0 and it seems to produce the same result. See here and if so please increase the priority.

@rmunn
Copy link
Contributor

rmunn commented Sep 15, 2021

Regarding @isaacHagoel's REPL example, which does $count.a = 11 inside the reactive block, I've recently seen code that specifically depended on the fact that store assignment inside a reactive block does not trigger the block again. I'll post a link if I can remember where I found it, but it was something along the lines of:

$: {
  if (typeof $cleanup === 'function') {
    $cleanup();
  }
  // ... code that needs cleanup ...
  $cleanup = () => { console.log("Cleaning up..."); };
  // Assignment above does not trigger an infinite loop
}

So what you're seeing in that example, where $count.a = 11 doesn't re-trigger the reactive block that uses $count, may be deliberate and just needs to be documented better. However, the more complex example from @arackaf's REPL is not the same thing, as it's entirely dependent on the order in which various $: blocks are called.

@isaacHagoel
Copy link

@rmunn looks like my issue happens regardless of stores. It is an issue with the reactivity itself.
See this REPL

I wonder if it is possible to reproduce the original issue without store. I would bet it is.

I agree that when there is a bug that there has been around for a long time there would be existing code relying on the buggy behaviour.
I wonder how I can work around this issue...

@Prinzhorn
Copy link
Contributor

Prinzhorn commented Sep 15, 2021

@isaacHagoel Isn't this a feature to prevent endless "reactive" loops/recursions? This is basically the halting problem, Svelte cannot determine (at compile time) if it's save to keep running the same reactive block again when the dependency that triggers it changes within the same block.

Edit: Basically what @rmunn said, I think that's different from the issue that @arackaf describes (which seems like a valid bug, changing the order of the blocks shouldn't matter, but I didn't dig into that REPL)

@isaacHagoel
Copy link

@Prinzhorn I can easily defend from infinite loop within my reactive block. I cannot workaround the current issue (maybe I can but haven't come up with a way yet).
Notice that if I add setTimeout it "works" which makes it even more confusing and still susceptible to infinite loops.
If it is different I am happy to submit a new issue but I suspect the original issue would reproduce without a store (I will try to find time to try it out later or tomorrow).

@Prinzhorn
Copy link
Contributor

I can easily defend from infinite loop within my reactive block

I'm sure you can, in the same way you can determine if a trivial turing machine will halt. But you can't do that for a non-trivial application. Your REPL is basically constant and the entire <script> block could be removed. If you can provide a more real world example I'm happy to show you a solution.

Notice that if I add setTimeout it "works" which makes it even more confusing and still susceptible to infinite loops.

There's a difference between a tight infinite loop and using a timer that happens on an entirely different event loop tick. The timer is no different from any other event (e.g. a click) and invalidates the variable as one would expect.

If it is different I am happy to submit a new issue

I think that would be better, because we shouldn't hijack this issue (unless you can show that it's actually the same issue).

I personally rely on the behavior you describe and to me this is not a bug at all but absolutely required. Here's a simple example from my code base (simplified code). It implements a "LazyTab" component that will only render when the visible property is true. However, it will from then on keep rendering it even if visible becomes false again (this is from an intelligent tab component that will lazily render tabs but then keep them rendered to retain state like scroll position etc., it will only hide them via CSS)

<script>
  export let visible;

  let render = false;

  // Once render becomes true (via visible) it will stay true forever.
  $: render = render || visible;
</script>

{#if render}
  <slot />
{/if}

If Svelte wouldn't do what it does, then $: render = render || visible; would infinitely keep setting render because it depends on render and so on. I'm sure I have more complex examples in my code base, some I might not even realize that Svelte saves me.

@rmunn
Copy link
Contributor

rmunn commented Sep 15, 2021

To hep with debugging @arackaf's issue, I took the compiled JS code of his two REPL examples and diffed it. After removing the few line-number comments whose line numbers had changed, the core of the diff was as follows:

--- example-1.js	2021-09-15 04:27:40.729899632 -0500
+++ example-2.js	2021-09-15 04:28:38.715858076 -0500
@@ -259,6 +259,15 @@
 	const click_handler = () => $$invalidate(0, currentNextPageKey = nextNextPageKey);
 
 	$$self.$$.update = () => {
+		if ($$self.$$.dirty & /*currentNextPageKey*/ 1) {
+			$: sync({
+				loading: true,
+				data: null,
+				loaded: false,
+				k: currentNextPageKey
+			});
+		}
+
 		if ($$self.$$.dirty & /*$queryState*/ 128) {
 			$: console.log("XXX ", $queryState);
 		}
@@ -278,15 +287,6 @@
 				}
 			}
 		}
-
-		if ($$self.$$.dirty & /*currentNextPageKey*/ 1) {
-			$: sync({
-				loading: true,
-				data: null,
-				loaded: false,
-				k: currentNextPageKey
-			});
-		}
 	};
 
 	return [

It seems that the $: statements are compiled in the same order they're encountered, so that moving the $: sync(...) call up also moved it up in the compiled JS. I won't have time to dig into this further today, but I bet there's something about the order of subscriptions that is causing this behavior.

@dummdidumm
Copy link
Member

I think this is the expected behavior.

The order of reactive statements is defined, and when triggered they run once. A reactive statement triggering another reactive statement will not make that statement rerun in the same tick in order to prevent endless loops (@Conduitry please correct me if I'm wrong).

In the example, there's no connection between $: ({ loaded, loading, data } = $queryState); and $: sync({ loading: true, data: null, loaded: false, k: currentNextPageKey }), which means Svelte will not reorder them. This means $: .. = $queryState); runs first, and $: sync ... runs afterwards. Because of the "only run once"-behavior, sync's update to $queryState will not make that reactive assignment run again.

@arackaf
Copy link
Contributor Author

arackaf commented Sep 15, 2021

@isaacHagoel - thanks a TON for simplifying my example. I knew there were simpler repro's, but it took me a few hours to get that one perfect, and I needed to move on.

@arackaf
Copy link
Contributor Author

arackaf commented Sep 15, 2021

@dummdidumm oh yikes - that seems really familiar, and I fear you might be right about this being expected, but I certainly hope not :( I thought for sure updating the store anywhere, anytime would re-trigger the reactive block, completely apart from compile-time analysis.

This is an extremely dangerous use case for non-demo, more realistic code.

@Conduitry
Copy link
Member

I haven't read the whole thread, but from @dummdidumm's comment, this sounds like the same question as came up in #5848.

@rmunn
Copy link
Contributor

rmunn commented Sep 15, 2021

Yes, I believe this is essentially a duplicate of #5848, though it wouldn't have been obvious right away that it was a duplicate. The docs say "Only values which directly appear within the $: block will become dependencies of the reactive statement," but that short sentence doesn't seem to be enough to help people grasp all the subtle implications. A couple examples, like the REPL examples in this issue and #5848, would probably be good for showing all the different ways you can get muddled with $: statements that have "hidden" dependencies (dependencies in a function called from the $: block but not visible directly in the $: block itself).

@arackaf
Copy link
Contributor Author

arackaf commented Sep 15, 2021

@Conduitry I fear it is. SO sorry to waste your time on this potential duplicate. This felt different, since I figured updating a store would break through that issue, but it seems not? I'd love to see a special case added to make this work with stores - dunno how feasible that is, though.

@arackaf
Copy link
Contributor Author

arackaf commented Sep 15, 2021

@rmunn solid points - yeah, this definitely didn't seem like the same issue. I'm still hoping there's a potential patch for this specific issue.

@isaacHagoel
Copy link

Thanks for the enlightening discussion above. I've created a separate issue and included my REPL examples there and what I am complaining about has nothing to do with the order of evaluation of reactive blocks and is reproduced with a single one.

@Rich-Harris
Copy link
Member

Rich-Harris commented Sep 20, 2021

Without weighing into the issue at hand right now (it's honestly not clear to me what the correct solution is here — the points about infinite loops are well made, the current behaviour is intentional but unfortunate in the case here where there isn't actually an infinite loop, merely a hidden dependency that prevents Svelte from finding the correct topological ordering), it took me a long time of staring at Adam's frankly bonkers repro before I could make sense of it, so here's a simpler version:

https://svelte.dev/repl/80a3e35ee61f42c0930b0a6d3f7115b1?version=3.42.5

<script>
  // exported so that `foo` is reactive
  export let foo = true;

  const obj = { loaded: true };
  
  function go() {
    obj.loaded = false; 
    setTimeout(() => {
      obj.loaded = false;
    }, 1000);
  }
  
  // (1) runs first, because it doesn't _look_ like it depends on (2)
  $: ({ loaded } = obj);
  
  // (2) runs second, invalidates obj, but Svelte doesn't run
  // reactive statements more than once per tick
  $: if (foo) go();
</script>

{#if loaded} 
  <h1>this text should never be visible</h1>
{:else}
  <h1>this text should be visible immediately</h1>
{/if}

@Rich-Harris
Copy link
Member

I will just add one thing that came out of a conversation with Adam — this might not be the best place for it but it's also not the worst — we occasionally run into situations where you want explicit control over the dependency list for reactive statements.

For example you might have something like this...

<Canvas>
  <Shape fill="red" path={...}/>
</Canvas>
<!-- Shape.svelte -->
<script>
  import { getContext } from 'svelte';

  export let path;
  export let fill;

  const { invalidate, add_command } = getContext('canvas');

  add_command((ctx) => {
    ctx.beginPath();
    // some drawing happens
    ctx.fillStyle = fill;
    ctx.fill();
  });

  // when `fill` or `path` change, we need to invalidate the canvas,
  // but `invalidate` doesn't need to know anything about `fill` or `path`
  $: (fill, path, invalidate());
</script>

That $: (fill, path, invalidate()) line is a bit weird. Conversely, if you want to ignore some dependency, you need to 'mask' it by hiding it inside a closure. If you want to log frequently_changing_thing whenever infrequently_changing_thing changes, but you don't want to log frequently, then you can't do this...

$: console.log({ infrequently_changing_thing, frequently_changing_thing });

...you have to do this:

function log_stuff(infrequently_changing_thing) {
  console.log({ infrequently_changing_thing, frequently_changing_thing });
}

$: log_stuff(infrequently_changing_thing);

This is all tangentially related to the issue in this thread because Adam suggested that if we did have a mechanism for expressing those dependencies, it could be treated as a signal to Svelte that reactive declarations should always re-run when those explicit dependencies change, infinite loop potential be damned. I actually don't think that's necessary — I reckon we could probably figure out a way to re-run (1) in the example above as a result of (2) changing without altering the behaviour that the cleanup example above depends on — but it's worth noting in any case.

So. Here's my idea for a way to (optionally, for those rare cases where you really do need to give explicit instructions to the compiler) declare dependencies for a reactive statement:

$: { fill, path } invalidate();
$: { infrequently_changing_thing } console.log({ infrequently_changing_thing, frequently_changing_thing });

The rule here is that if the $ labeled statement's body is a BlockStatement whose body is an ExpressionStatement whose expression is a SequenceExpression whose expressions is an array of Identifier nodes, it is treated as a dependency list, and the next node in the AST takes its place as the reactive statement.

Since $: { a, b, c } is meaningless code, I think you could even make the case that it's a non-breaking change.

The one gotcha is Prettier...

// before
$: { a, b, c } { console.log({ a, b }) }

// after
$: {
  a, b, c;
}
{
  console.log({ a, b });
}

...but I'm fairly sure the plugin could solve that.

@Conduitry
Copy link
Member

Some other gotchas:

  • We're currently encouraging break $; if people want to bail out of a particular reactive block, which just automatically works because we leave the $: label on the surrounding block in the generated JS. However, Acorn will complain if there's a break statement referring to a label it can't see on a containing block - and the label will now be on the previous block.
  • What happens with auto-declaring reactive declarations? Are there special syntax or behavior considerations to be made there? Do we want to just disallow that?

Is there some other syntax where the explicit list of dependencies can occur within the reactive block, but do so in a way we can sneak in in an effectively non-breaking way?

@arackaf
Copy link
Contributor Author

arackaf commented Sep 20, 2021

Reactive blocks don’t currently return anything, do they? Could a reactive block return the dependency array?

$: { 
  foo(a, b, c);
  return [a, b]
}

@Rich-Harris
Copy link
Member

Good point re break. I guess you could solve it by swapping the order:

$: {
  if (x) break $;
  console.log(y);
} { y }

It's definitely less aesthetically appealing (to me at least) though. I guess this monstrosity could work for the cases where you need break:

$: { y } $: {
  if (x) break $;
  console.log(y);
}

// or if you didn't want to reuse $
$: { y } fast: {
  if (x) break fast;
  console.log(y);
}

What happens with auto-declaring reactive declarations?

I was imagining they'd continue to work the same way — no change in syntactic validity.

Is there some other syntax where the explicit list of dependencies can occur within the reactive block, but do so in a way we can sneak in in an effectively non-breaking way?

You could certainly do this sort of thing...

$: {
  [fill, path];
  invalidate();
}

...but it's arguably less clear (hard to tell that you could shorten the dependency list rather than lengthening it that way) and only works with block statements.

Could a reactive block return the dependency array?

No, that's an invalid return statement because you're not inside a function body.

@arackaf
Copy link
Contributor Author

arackaf commented Sep 20, 2021

@Rich-Harris I think your last example is on to something. Have the dep array be the last expression in the block, and I think that would look pretty good.

@arackaf
Copy link
Contributor Author

arackaf commented Sep 20, 2021

@Rich-Harris come to think of it, your first example is really, really good too. If the dev list is last that’s closer to what React currently does …

@Conduitry
Copy link
Member

As long as we'll carry over whatever label the user may have put on the next block into the compiled JS, I think requiring folks to explicitly add another label makes the most sense. I agree it's more confusing to put the dependencies after the main block, and I don't think we should inconvenience typical use for the sake of break.

Returning from syntax back to functionality: Is your idea that we would keep the current "only run once, runs can't synchronously trigger additional runs" behavior, but that we would now take into account the overridden dependencies when topographically sorting the reactive blocks? That sounds sufficient to me, I think. I'm not a fan of the idea of cycling through the reactive blocks until everything settles.

@arackaf
Copy link
Contributor Author

arackaf commented Sep 20, 2021

@Conduitry Rich did explicitly say that this would allow for potentially infinite reevaluations of the reactive blocks, which I think is absolutely essential (though he can speak for himself if I misunderstood).

React (and other frameworks to my knowledge) do not give you ONE opportunity for your side effects to run, requiring careful ordering of them.

@Rich-Harris
Copy link
Member

Returning from syntax back to functionality: Is your idea that we would keep the current "only run once, runs can't synchronously trigger additional runs" behavior, but that we would now take into account the overridden dependencies when topographically sorting the reactive blocks

Ideally yes — I reckon it's better that we settle on one rule for when reactive statements evaluate, and for this to be a non-breaking change it would have to be the current rule (which I think makes sense in any case). The wrinkle is that if we did want to later change the behaviour for blocks with explicit dependency lists, that would be a breaking change. So we probably want to be sure.

But it's worth noting that explicit dependency lists wouldn't be a tool for solving the problem in @arackaf's example. The issue isn't that statement (1) doesn't know that it reads obj, it's that statement (2) doesn't know that it writes obj. The fundamental problem is one of ordering — Svelte can't sort the statements topologically if reads or writes are hidden from view — and there are two potential solutions that spring to mind:

  1. Track which reactive statements caused which bits to get dirty. Re-run all reactive declarations until there are no more changes, but skip any statements that caused themselves to become dirty
  2. Detect any 'hidden writes' and warn that the ordering is incorrect as a result, probably only in dev mode

(The third solution is to have a Sufficiently Smart Compiler that identifies that the call to go writes to obj, but that's a non-starter.)

Of those, I prefer the second. The actual solution to the problem is very simple — (1) and (2) should be swapped — and guiding the developer towards that solution is almost certainly better than the considerable book-keeping and wasted computation that the first would involve.

@rmunn
Copy link
Contributor

rmunn commented Sep 20, 2021

For clarity, would $: { (a, b, c) } { console.log({ a, b }) } also be allowed? Depending on the length of the identifiers, some people might want to split it onto multiple lines, in which case the parentheses would be useful. If (a, b, c) turns into the same SequenceExpression in the AST that a, b, c does, then of course the answer is yes and this would come for free.

@Rich-Harris I think your last example is on to something. Have the dep array be the last expression in the block, and I think that would look pretty good.

Ugh, no, please. Putting the dependency array last is one of React's ugly bits. It's pretty much necessary if the dependency array is a function parameter, because it's hard (and in some cases impossible) to deal with optional parameters in non-final positions, so I understand why React did it that way. But putting the dependency list last makes you read the code block twice to ensure that you've fully understood it ("wait, let me double-check: when x changes, the block re-runs. Is that the logic I want?"), whereas putting the dependency list first means you only have to read the code block once, and the dependency list ends up working a little bit like a list of function parameters so you already have a mental framework in place to understand it.

Count me as a solid vote for the dependency list coming first, not last.

@Conduitry
Copy link
Member

Ah. Yes, this new syntax would just let you override the dependencies for a given reactive block, but it wouldn't let you specify which ones it's writing to. I guess that logic would continue to work as it is now - whichever variables are assigned to within the reactive block? Does it make sense to try to come up with a syntax that lets component authors specify that as well?

If we're detecting hidden writes in dev mode and issuing a warning, are you worried about that warning irritating people who already explicitly wrote their reactive blocks in such a way as to hide an assignment from the compiler? It's not officially documented anywhere, but we've already been pushing people towards writing code like $: foo, bar, baz, do_something();. Would that start emitting warnings for code we've already been telling people to use?

@arackaf
Copy link
Contributor Author

arackaf commented Sep 20, 2021

I’m disappointed to hear the preference for (2) over (1). Having to keep track of the ordering of reactive blocks, and manually move them around would be a somewhat unique burden among JS frameworks. It’s worth noting that React will never, ever care which order your useEffect calls are listed in.

Interestingly I had assumed the explicit dep lists would trigger behavior (1) for the explicitly listed deps. But if you’re even thinking of extending that to the general case, I hope you’ll reconsider whether that would be wasteful work. Pulling that off would make Svelte as simple to use at scale as it is in fun little demos.

@arackaf
Copy link
Contributor Author

arackaf commented Sep 24, 2021

I think "freed from needing to fully understand the reactivity model" isn't quite right. Isaac's comment from before is worth repeating in part

but currently [the rules] don't allow one to build a coherent mental model of how reactivity would behave, even if you are an expert, without always being on the guard, worrying and testing

"Understanding" the reactive model isn't the problem. It's getting it to behave in a consistent way. Currently the reactivity model allows for weird actions at a distance. Add a reactive block here, and another one stops working, for ostensibly baffling reasons. A warning might help, usually, but I still think for complex apps that warning would be insufficient (third party libraries like Apollo, etc could easily pollute those line numbers making it difficult for the app developer to know what the hell to do).

I also think the perf risks are low. This problem happens rarely. Svelte is already massively faster than React, and React is already more than fast enough to be incredibly popular, and effective at building profitable software. I'd be shocked if some additional tracking bits affected this in any meaningful way, and would (imo) be a pretty good tradeoff to gain consistency in how the reactivity works.

Trading a small amount of perf for better consistency is a good tradeoff.

@Rich-Harris
Copy link
Member

It's getting it to behave in a consistent way

Which is why I addressed the bugs that cause inconsistencies

I also think the perf risks are low. This problem happens rarely. Svelte is already massively faster than React

One of the reasons for that is that the Svelte team is disciplined about design questions just like this one. Each tradeoff seems fine by itself, at the time, but these effects accumulate. We're talking about penalizing every single user of Svelte apps in order to duct tape over a problem that a tiny minority of Svelte developers have encountered. I know it seems like a huge thing to you, but we have a larger constituency to take care of.

Anyway, we're back making the same points in a loop, so I'm going to bow out of this thread.

@arackaf
Copy link
Contributor Author

arackaf commented Sep 24, 2021

a problem that a tiny minority of Svelte developers have encountered

It's worth noting that this is a tiny minority of Svelte users right now. If Svelte continues to gain marketshare, and get used more often in the larger, more complex apps that React is currently the de facto choice for, this will come up more and more.

This is exactly why I asked if Svelte should not be considered for apps like that. If Svelte is not designed for those sorts of complex apps, then that's fine. But if Svelte is in theory designed to handle the same serious apps that React handles today, it is absolutely imperative that it behave consistently. This is what Isaac was trying to get across when he said "As much as I love and enjoy Svelte, I do think that this is of a non-starter for new complex projects." I agree with that, and I imagine most engineers who've maintained these sorts of apps at scale would, too.

@arackaf
Copy link
Contributor Author

arackaf commented Sep 24, 2021

Just to leave on a positive note, I only care because I love Svelte. A lot. I'd love to see it get to a place where it could be used on complex apps at scale. That's why I'm pushing for this sort of change.

If that's not what Svelte is striving for, then I'll keep loving Svelte for smaller, personal projects.

React is a pain but getting paid 💵 💴 💶 💷💰 to use it has a nice way of numbing that pain 😂

@Rich-Harris
Copy link
Member

Going to share some current thinking. I think what this all boils down to is the fact that Svelte conflates two things, which are actually quite different:

  • reactive declarations, which calculate something and
  • reactive statements, which do something

In CommandQuerySeparation Martin Fowler writes:

It would be nice if the language itself would support this notion

I think we can alleviate a lot of confusion by separating the two things at the level of syntax. And I think we can even do it in a backwards-compatible way, that would provide an escape hatch for stuff that you really do want to run until it 'settles' while still steering people towards better outcomes:

$: a = b + c;
$: d = a * 2;

run: if (a > 10) {
  console.log('resetting');
  b = c = 0;
}

run: console.log({ a, b, c, d });

Here, $: retains its existing meaning. (In Svelte 4 we could arguably make a breaking change by insisting that it always be followed by an assignment, but there are some scenarios where that would be annoying.) As long as you don't violate command-query separation, things will work exactly as you expect.

run:, on the other hand, would happen after those updates (even if $: and run: are interleaved in your source code). It would loosely be equivalent to this:

$: a = b + c;
$: d = a * 2;

$: tick().then(() => {
  if (a > 10) {
    console.log('resetting');
    b = c = 0;
  }
});

$: tick().then(() => {
  console.log({ a, b, c, d });
});

In the original repro, the only thing that would change is this:

-$: sync({ loading: true, data: null, loaded: false, k: currentNextPageKey });
+run: sync({ loading: true, data: null, loaded: false, k: currentNextPageKey });

All other $: lines are calculate something statements and would therefore be unchanged.

Similarly, @isaacHagoel's repro could be updated thusly:

-$: if ($state.items) {
+run: if ($state.items) {

I haven't considered the full details of this proposal yet, but it seems like something that would give developers the ability to write code that needs to run in a loop without compromising on performance for everyone else.

@Mlocik97
Copy link
Contributor

Mlocik97 commented Sep 24, 2021

Can't we just add:

$: tick().then(() => {});

example to docs? I think having two so similiar features would be confusing when to use which one. Mainly for begginers.

@arackaf
Copy link
Contributor Author

arackaf commented Sep 24, 2021

@Rich-Harris that's fucking brilliant. I love it. Solves all of the problems here. It's exactly the sort of feature / escape hatch a framework should have.

I'm sorry this took such a painful thread to get here, but I'm incredibly excited to see this thinking :)

Bike shed, should it maybe be

$run: if (a > 10) {

to be more consistent???

Or, since this seems to be sugar for

$: tick().then(() => { 
  code 
});

maybe

$tick: { 
  code 
}

might be another option.

Really though I don't care. Seriously stoked to see this being considered.

@arackaf
Copy link
Contributor Author

arackaf commented Sep 24, 2021

@Mlocik97 it's one more feature for beginners to learn, eventually, maybe (you can get extremely far without it), but it unlocks a lot, and simplifies a lot.

@arackaf
Copy link
Contributor Author

arackaf commented Sep 24, 2021

@Rich-Harris I think your example works even today, without :run. Would I be correct in assuming (hopefully) that it would still work if you changed it to this:

$: a = b + c;
$: d = a * 2;

const reset = () => b = c = 0;

run: if (a > 10) {
  console.log('resetting');
  reset();
}

run: console.log({ a, b, c, d });

@arackaf

This comment has been minimized.

@Mlocik97
Copy link
Contributor

it's one more feature for beginners to learn

I agree, but those two features would in a lot of case produce same result, and that would make junior devs (begginers) confused. I can already feel, how they would ask about difference between these two five times every day on Discord.

@arackaf
Copy link
Contributor Author

arackaf commented Sep 24, 2021

@Mlocik97 I'm sure a good section in the docs would make that clear. Ie, for super advanced use cases, that you probably won't even need, ever, etc etc etc. I don't think good features should be left off because some juniors might have a bit of confusion around it.

EDIT - actually, it looks like Rich is more or less saying

run: would be a replacement for $: {

blocks, which would leave $: mostly for assignment. Seems he's lightly considering having run: be a replacement for $: {

Fine with me either way - I suspect that'd be a tough breaking change to make happen. Really I'm just ecstatic to see features being contemplated that would add the sort of consistency Isaac and myself have been talking about.

@isaacHagoel
Copy link

@Rich-Harris @Mlocik97 @arackaf
mmm.. I spent some hours playing with $: tick().then(() => vs. watch (derived stores) vs. Rich's useEffect and I think I am starting to go a bit insane 😺
Point 1:
When I try Rich's example code (for run) with these 3 mechanisms, here is what I get:

  1. $: tick().then(() => doesn't compile, complains about a cyclical dependency. See here
  2. Rich's useEffect doesn't console the final result, so it's not running all the way through. Changing the order of the blocks doesn't seem to fix it. See here. Changing the order of the blocks changes the output so no good.
  3. watch prints the correct output but changing the order of the block has a pretty drastic effect on the amount of printouts. This is because every state change causes it to start again from the top. See here

Point 2:
I tried other examples and $: tick().then(() => seems to be behaving correctly for everything I tried. Even though it doesn't "start from the top" every time like watch does, it seems to always get to the correct end result (more efficiently that watch), which makes me wonder whether always using it instead of $: would solve the problem in practice. Maybe there is a subtle issue I haven't tested for but is going to bite in reality (?).
If this pattern makes everything run to resolution, no matter the order or anything ( == it is consistent and predictable), then I am okay with documenting it and going back to our lives as @Mlocik97 proposed.

Point 3:
Not sure I fully wrapped my head around the implications of combining normal $: blocks (for assignment only) with $: tick().then(() => (or run) blocks. Could the "one per tick" nature of the normal $: blocks get things out of sync somehow even if all they do is assignments? Does it make the mental model simpler or more complicated? easier or harder to reason about? I am not sure... I tend to want everything to be the same.

Point 4:
I think I would personally prefer just one keyword/syntax but being able to provide options to it. So $: stays the entry point to reactivity but maybe it take an options object somehow, something like what @dummdidumm said here and @rmunn seems to also support. This approach also makes it more easily extensible. The defaults for all of the options would be the current behaviour so it is all nice and backwards compatible.

@rmunn
Copy link
Contributor

rmunn commented Sep 25, 2021

I can offer an explanation for one of your issues:

Point 1:
2. Rich's useEffect doesn't console the final result, so it's not running all the way through. Changing the order of the blocks doesn't seem to fix it. See here. Changing the order of the blocks changes the output so no good.

That's because useEffect in that REPL is using afterUpdate, which is designed to run after the component's visible fields are updated. With no HTML in that component, nothing happens. If I add {a} to the HTML, so that an update is triggered every time the value of a changes, then the final result is logged to the console.

@isaacHagoel
Copy link

isaacHagoel commented Sep 25, 2021

@rmunn adding {a} makes useEffect work only sometimes. It seems that it is still sensitive to the order of the calls and doesn't always run all the way through. For example see here

@isaacHagoel
Copy link

@rmunn (continued) only rendering {a}{b}{c}{d} seem to make it run to its conclusion in any order of blocks but this still means it is not something that can be used in reality (we don't render every variable).

@isaacHagoel
Copy link

I would add that I was curious how other frameworks would deal with this test case.
First I tried Solid because it has a reputation for having one of the most, well, solid reactive systems (that self infers the dependencies like Svelte does).
Seem to be behaving as expected with no surprises and very consistently. See here. It is not sensitive to the order of the effects and always logs 6 times.

React also resolves correctly although it has some sensitivity (number of operations) to the order of the effects. See here

How can we get svelte (which, generally speaking, has far superior DX relative to both of the above) to the same level of predictability and robustness?

@Prinzhorn
Copy link
Contributor

Prinzhorn commented Sep 26, 2021

I want to point out that run: would technically be a breaking change, this is valid JavaScript right now https://svelte.dev/repl/c33e6f476f5546c9937846ba14296a81?version=3.43.0 . But it could be a compiler option that, if disabled (by default) will warn that you are using the run label?

However, I do like the separation, because it solves #4933 + #4933 (comment) by making it clear that these are reactive declarations and hence immutable (?)

@Mlocik97
Copy link
Contributor

Mlocik97 commented Sep 26, 2021

I mean, there is already a lot of stuff that use run as name of variable, function or anything else, in a lot of libs... I would consider to name it for example @: instead, or maybe there is even better name? $$: is too ugly. I have no other idea now. Still I'm not sure about this at all.

@rmunn
Copy link
Contributor

rmunn commented Sep 27, 2021

I mean, there is already a lot of stuff that use run as name of variable, function or anything else, in a lot of libs...

Label names are a separate namespace from variable/function/etc names, so there would be no conflict: https://svelte.dev/repl/eb2a4726339840ea90a7d421cbb29355?version=3.43.0

Having said that, I would prefer something prefixed with $ like $run, or even $runUntilSettled to be more explicit about what it does.

@Mlocik97
Copy link
Contributor

oh I didn't know this,... I always was naming labels differently, if I used them (that was already rarely). Thanks for info, I learned something new again.

@isaacHagoel
Copy link

isaacHagoel commented Oct 5, 2021

Not sure if this is still on people's mind or if everyone moved on to other issues.
In the meanwhile, I wrote this post on dev.to. Dev.to articles have good presence in search engines so folks googling around would hopefully bump into it until the official docs cover these issues (or until they are addressed otherwise).
Cheers.
cc @arackaf

@arackaf
Copy link
Contributor Author

arackaf commented Oct 5, 2021

@isaacHagoel This looks like a great write-up - thanks!

I know Rich is on vacation right now, but it sounds like he's open to improving the reactivity system to get it to a place where it'll scale to apps at your level, so I'm looking forward to seeing what comes of this.

@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.

@dummdidumm dummdidumm added this to the 5.x milestone Nov 15, 2023
@benmccann benmccann modified the milestones: 5.x, 5.0 Nov 17, 2023
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

10 participants