-
-
Notifications
You must be signed in to change notification settings - Fork 4.4k
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
Derived stores are re-evaluated on invalid upstream state #10376
Comments
Moving context from #10451 ExampleConsider the following example: const a = writable(1);
const b = derived(a, a => a*2);
const c = derived([a,b], ([a,b]) => a+b);
c.subscribe(c => console.log(c));
...
<input type=number bind:value={$a} /> This creates a dependency graph something like: stateDiagram-v2
direction RL
input
a
b
c
log
log --> c
c --> b
b --> a
c --> a
a --> input
Svelte's Current ImplementationWith sveltes current implementation, the derived store c will prematurely evaluate every time store a changes. sequenceDiagram
autonumber
participant input
participant a
participant b
participant c
participant log
note right of a: a == 1
note right of b: b == a * 2 == 1 * 2 == 2
note right of c: c == a + b == 1 + 2 == 3
input ->> a: 2
note right of a: a == 2
a -->> c: invalidate
activate c
a -->> b: invalidate
activate b
a ->> c: 2
deactivate c
rect rgb(255, 128, 128)
note right of c: c == a + b == 2 + 2 == 4
c ->> log: 4
end
a ->> b: 2
deactivate b
note right of b: b == a * 2 == 2 * 2 == 4
b -->> c: invalidate
activate c
b ->> c: 4
deactivate c
note right of c: c == a + b == 2 + 4 == 6
c ->> log: 6
Following the diagram, it's clear at point (5) that store The correct behaviour would be for invalidations to be "deep" and for resolution to only occur once all dependencies are fully resolved. Thus in the given example, Why fix it?Prematurely evaluating a derived store in many situations would result in just a brief glitch - an incorrect calculation immediately followed by the correct one. But in many contexts, the derived store subscriptions result in side effects. Depending on the nature of these side effects, the results of a premature evaluation with incorrect data may be quite pronounced - sending data to a service, permanently modifying data, crashing an application. ReproductionSee: semi-diamond dependency problem REPL As a head start for any patch request, here is a test case: it('only updates once dependents are resolved', () => {
const a = writable(1);
const b = derived(a, a => a*2);
const c = derived([a,b], ([a,b]) => a+b);
const values: number[] = [];
const unsubscribe = c.subscribe(c => {
values.push(c);
});
a.set(2);
a.set(3);
assert.deepEqual(values, [3, 6, 9]);
}); note that the above test case fails with the current implementation |
@mnrx, what was you basic solution for resolving this? The solution I went for was to have an "invalidate" and "revalidate" option for the subscribe method. A deep invalidation was done whenever a change was made and the revalidation was necessary to support alternate equality checks and thus wouldn't be necessary with the default greedy implementation. In the context of fixing just this issue, I believe simply performing a deep invalidation may be sufficient which would be a much smaller code change. |
I've written a preliminary patch to resolve this (see https://github.com/WHenderson/svelte/tree/develop/stores-diamond-dependency) and in doing so I've noticed the test case "derived dependency does not update and shared ancestor updates". As it stands, this existing test case codifies the exact behaviour we are trying to avoid by raising this issue. This decision appears to have been made in response to #3191 / bbeafba |
FYI, this is what I believe the correct behaviour should be: sequenceDiagram
autonumber
participant input
participant a
participant b
participant c
participant log
note right of a: a == 1
note right of b: b == a * 2 == 1 * 2 == 2
note right of c: c == a + b == 1 + 2 == 3
input ->> a: 2
note right of a: a == 2
a -->> c: invalidate
activate c
a -->> b: invalidate
activate b
b -->> c: invalidate
a ->> c: 2
a ->> b: 2
deactivate b
note right of b: b == a * 2 == 2 * 2 == 4
b -->> c: invalidate
b ->> c: 4
deactivate c
note right of c: c == a + b == 2 + 4 == 6
c ->> log: 6
Notably, the derived store doesn't compute until all of its dependencies are resolved. |
Thank you for spending time on this. Abandoned Approach
After giving some thought to the general problem of managing reactive state with dependencies, I noticed that this bug can be avoided by only ever re-evaluating stores in the order that they are defined. Using the Svelte store API, at least, I don't believe it's possible to create a store that is dependent on another store which is defined later in the source code. A different store API or a different programming language may make this possible, but would also open the door to dependency rings in doing so. On a side note, Rich Harris mentions spreadsheet software in an old presentation of his, pointing out the similarity of keeping an application UI up-to-date to keeping formulaic cells in a spreadsheet up-to-date. Even now, trying to create a circular dependency in Numbers on macOS yields the following error: "This formula can’t reference its own cell, or depend on another formula that references this cell." I think the limitation of only being able to depend on already-defined stores is reasonable, and simplifies store implementation. With this in mind, I modified
In practice, this solution moves a significant amount of logic out of the stores an into some kind of "resolver" that monitors them. It also required keeping a reference to every store created so that those stores could be later accessed by ID. Thirdly, I had a feeling that delayed creation of stores, such as in a component not shown when the application loads, would present further issues. These and other difficulties led me to abandon this approach in favour of that described in PR #9458. That approach is described in the PR and in the points I make in the next section, but please ask questions if those explanations don't cover enough. Ultimately, I don't believe it possible to build a store implementation that handles complex graphs correctly unless a control layer is created above the stores, or the individual stores have more knowledge of their place in the graph. This context is provided in the algorithm above by a store's ID, and it's provided in my implementation by the invalidation step. Diagram and Patch ImplementationYour diagram and patch implementation seem to match each other, but I think I can identify two issues with them. Consider the following store graph—stores A and B are writable and the rest are derived: stateDiagram-v2
direction RL
# classDef Readable font-weight:bold,stroke-width:3
# class C Readable
E --> B
C --> A
D --> A
E --> C
E --> D
F --> E
In your patch implementation, the counting logic is handled by the bitwise operations on the I will confess that I still believe a re-implementation is required rather than a small patch. I found the current implementation particularly inscrutable when I was tracing the origin of the bug. Extra complexity is also introduced, for instance, by Svelte passing both TestsI tested your patch implementation against the test suite in my original PR, and there are three more cases that fail. The relevant ones start on line 523 of tests/store/test.ts. The following tests fail:
Lastly, issue #3191 describes a valid problem, and I agree with the reporter's expected behaviour. My implementation passes the corresponding test, and I'm not sure why your patch implementation doesn't—I suspect it relates to point 2 above. |
Hi @mnrx , the stores aren't keeping a simple boolean invalidation state. They are keeping a boolean for each dependency and will only resolve/execute once all are in a valid state. This is the way the existing code works, my only addition in the context of this patch is to do it "deeply". I had originally made this explicit in the diagrams, but figured it overcomplicated them and distracted from the original message. To be 100%, I implemented a test case for what you described and it works as desired.
I disagree. Stores are set to invalid (perhaps a poor choice of word) when their value becomes indeterminate/pending. Until an invalid derived is re-executed, it cannot be determined if it will evaluate to the same value.
|
Thank you for implementing that graph and testing against it.
This is true—I should have mentioned another relevant detail. In my implementation, stores will emit an update notification even if their value has not changed, but only if the subscriber is a derived store. This notification includes a boolean value passed with it; Looking again at your patch implementation, I think the invalidation initiated by derived stores may be unnecessary but it would require more logic to disable that step for derived stores specifically.
This is fair, but would your preferred behaviour be for the whole store graph to be delayed in updating because one store is asynchronous? Svelte supports stores that change their value asynchronously, such as a store which updates its value after a time delay: const foo = writable(false);
const fooChangedRecently = derived(
[foo],
([$foo], set) => {
set(true);
setTimeout(() => set(false), 3_000);
},
false,
); In this example, the store Critically, if a derived store's value is to be considered invalid from the point of re-evaluation until an asynchronous action completes, I think the author should include an explicit synchronous call to On a related note, I should have mentioned that asynchronous calls to Validity Counter vs. Bit FieldSome of our disagreement about the details of the algorithm probably stems from my decision to use a counter to represent invalidity rather than a bit field, as used by the current Svelte implementation. The bitwise logic seemed opaque to me, and limits derived stores to (probably) at most 32 dependencies—no dire limitation, to be sure. I opted to use a counter, which requires exactly as many invalidation notifications as update notifications to flow downstream in the graph. Alternatively, I could have used a Set holding dependency indices, which would still remove the dependency limit and make the logic more readable while also not introducing a need to track invalidity exactly. This approach may be a more robust one, if slightly more memory-intensive. Tests
This test case is still failing for me. It was marked to be skipped in your patch—was that change intentional? I think the test fails because store Disabling
|
The invalidation initiated (continued) by derived stores is what provides a solution to the semi-diamond dependency problem. without this, the sequence is exactly the same as sveltes current implementation.
This is the current behaviour of async svelte stores. The patch doesn't really address any of the operational behaviour of async stores, it seeks only to address the "diamond dependency" issue in a more thorough way. If we are to look into addressing some of the issues of async stores I would look to do that in a separate issue.
This is a different issue best addressed in a separate issue.
I misread your earlier post. You are correct, it does fail with the changes.
I agree that this is something of a foot gun, but should be raised as a separate issue. As it stands, I think the existing patch addresses the specific issue raised in this post. It would be good to hear from the maintainers about #3191 / bbeafba, but it may be best to do that via a PR. |
Correction
This is actually failing because I tried to skip part of my original solution (revalidation). |
That invalidation is necessary, yes, but the following round of invalidation initiated by the derived store setting its new value is not. It would require more logic in
At least in my testing, your patch implementation outputs If I'm interpreting issue #3191 and PR #3219 correctly, Svelte did have an algorithm like we're proposing before. To quote an old version of the codebase, " Regarding creating separate issues, that's fair. |
Yes, you are correct. Good catch.
I'll have to take a look at this when I have some time. |
svelte 4 at the point where
my patch #10557 When Rich's patch #10575 |
Hi, just want to chime in: the problem of managing derived store had been pretty studied in incremental computation. The eager approach
see: Self-Adjusting Computation The lazy approach
pros of the eager approach:
pros of the lazy approach:
none is strictly better then the other. |
Hi @MarisaKirisame, this issue is now linked to #10575 (Rich Harris) and #10557 (mine). The question at this point is about how best to minimally extend the API to support un-dirtying a store. |
Describe the bug
Note: The following block quote is from #9458.
See my PR (#9458) for a different implementation of
svelte/store
which avoids this issue. My implementation treats derived stores, in their capacity as a store subscriber themselves, with extra attention. This enables more accurate tracking of derived store validity and ensures that re-evaluation doesn't happen on invalid upstream state.Reproduction
The following example has sound application logic. If the two derived stores were combined into one, it would work as expected. Instead, Svelte re-evaluates the
assertion
store twice for every change in the writable storenumber
. The first of each of these re-evaluations feeds invalid state toassertion
's update function, producing an invalid output, e.g., "3 × 3 is even".Logs
Regarding logs, no exceptions are thrown unless the invalid state causes the derived store's update function to throw one. I encountered this issue while writing a derived store which attempted to access a property of an object in an array, with both the array and index being the values of other stores. In this case, the error would show "Uncaught TypeError: can't access property "foo" of undefined."
System Info
n/a
Severity
Blocking all use of
svelte/store
.The text was updated successfully, but these errors were encountered: