-
-
Notifications
You must be signed in to change notification settings - Fork 4.3k
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
fix: invalidate derived stores deeply #10575
Conversation
🦋 Changeset detectedLatest commit: 78dc776 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
return { | ||
subscribe: writable(value, start).subscribe | ||
// @ts-expect-error we don't want this to be on the public types | ||
[SUBSCRIBERS_SYMBOL]: w[SUBSCRIBERS_SYMBOL], |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should this be made non-enumerable then?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
symbols are already non-enumerable, no?
obj = {
[Symbol()]: 1,
foo: 2
};
for (const key in obj) {
console.log(key, obj[key]); // logs `foo 2`
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Object.assign()
copies enumerable symbols as does object spreading.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it's probably harmless — people aren't really spreading or assigning the results of calling writable
and readable
, and there's not much damage that could be caused by people inadvertently getting a reference to the subscriber list. Feels like overkill to use Object.defineProperty
here
I tested this implementation against a few test cases I created for my own implementation. I think this change introduces a regression: If the derived store's function The following test case generates it('does not notify subscribers if the derived value is unchanged', () => {
const values: number[] = [];
const number = writable(1.1);
const round_number: Readable<number> =
derived(number, ($number) => Math.round($number));
const unsubscribe = round_number.subscribe((value) => values.push(value));
assert.deepEqual(values, [1]);
number.set(1.8);
number.set(2.2);
number.set(2.4);
assert.deepEqual(values, [1, 2]);
number.set(2.9);
assert.deepEqual(values, [1, 2, 3]);
unsubscribe();
}); Also, in case you aren't aware, this PR appears to revert the behavior introduced in PR #3219. I much prefer this behaviour, but it's worth noting. As I gather, the original motivation for that PR was performance, which has me considering to build a set of more complex store graphs for benchmarking. |
Haha, you beat me by a few minutes @mnrx :-D I'm not sure the best way to contribute to the existing PR, so for now here is a change to one of the test cases to catch the issue: it('derived dependency does not update and shared ancestor updates', () => {
const root = writable({ a: 0, b: 0 });
const values_a: string[] = [];
const values_b: string[] = [];
const a = derived(root, ($root) => {
return 'a' + $root.a;
});
const unsubscribe_a = a.subscribe((v) => {
values_a.push(v as string);
});
const b = derived([a, root], ([$a, $root]) => {
return 'b' + $root.b + $a;
});
const unsubscribe_b = b.subscribe((v) => {
values_b.push(v as string);
});
assert.deepEqual(values_a, ['a0']);
assert.deepEqual(values_b, ['b0a0']);
root.set({ a: 0, b: 1 });
assert.deepEqual(values_a, ['a0']);
assert.deepEqual(values_b, ['b0a0', 'b1a0']);
unsubscribe_a();
unsubscribe_b();
}); The issue here is that the change to the internal Without a separate "revalidation" api, the only alternative I see is to detect subscribers that don't provide an invalidation handler and treat them differently, but this feels like a kludge with limited utility. Some other notes on the implementation:
something like: () => {
const signal = !pending;
pending |= 1 << i;
if (signal) {
// @ts-expect-error
for (const s of r[SUBSCRIBERS_SYMBOL]) {
s[1]();
}
}
}
|
I had a bit more of a think about this and realised there is an added advantage to an accessible "subscribers" list. e.g.
In this example, the derived store wont be updated until each of a/b/c are all updated instead of updating 3 times. I haven't personally come up against the need for transactionally setting a group of store values, but I can see the utility. |
On reflection: I'm going to close this and #10557, because ultimately the real solution to this problem is 'use signals'. While stores will continue to exist in Svelte 5, they're not something we want to invest in. This PR introduces a regression while #10557 involves API changes; both involve more code. I think it makes more sense to keep stores working as they do in Svelte 4, however imperfect that may be in these cases, and push people towards the modern way of doing things. Thank you @WHenderson for the time spent working on this and the thorough explanation. |
No worries @Rich-Harris , I understand prioritising Svelte 5 and signal usage. I'm looking forward to using them :-) |
This is an alternative to #10557 which solves the problem in a slightly different way, by giving the
derived
store access to its own subscribers (so that in can invalidate them deeply). It's the same solution, at a high level, but with arguably more minimal changes to the visible API surface area, and a bit less code (a net increase of 25 LOC rather than 75)Before submitting the PR, please make sure you do the following
feat:
,fix:
,chore:
, ordocs:
.Tests and linting
pnpm test
and lint the project withpnpm lint