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

feat: generic utility for making any class reactive #11504

Open
wants to merge 29 commits into
base: main
Choose a base branch
from

Conversation

FoHoOV
Copy link

@FoHoOV FoHoOV commented May 7, 2024

This is a completly new take on the reactivity package. After issues like #11222 and solutions like #11200, #11385 and #11287 we still have data duplication and a complex solution (also some other issues which makes current implementation not fine grained like #11515). This implementation allows us to make any (hopefully) builtins reactive without any code repetition in more flexible and generic way. This we only care about notifying the read methods about changes and not the data management itself. This pr also uses the #11287 idea to only create signals when they are actually read, not on initialization which could be a performance buff - for instance in my tests with a map which has 1_000_000 elements, the page load speed increased by 1s.

if the idea is accpeted and actually good I'll refactore other reactivity package classes to use this utility instead.

using this utility on Set for instance resulted in:

export const ReactiveSet = make_reactive(Set, {
	write_properties: ['add', 'clear', 'delete'],
	read_properties: ['has'],
	interceptors: {
		add: (notify_read_methods, value, property, ...params) => {
			if (value.has(params[0])) {
				return false;
			}
			notify_read_methods(['has'], params[0]);
			return true;
		},
		clear: (notify_read_methods, value, property, ...params) => {
			if (value.size == 0) {
				return false;
			}
			notify_read_methods(['has'], NOTIFY_WITH_ALL_PARAMS);
			return true;
		},
		delete: (notify_read_methods, value, property, ...params) => {
			if (!value.has(params[0])) {
				return false;
			}
			notify_read_methods(['has'], params[0]);
			return true;
		}
	}
});

Svelte 5 rewrite

Please note that the Svelte codebase is currently being rewritten for Svelte 5. Changes should target Svelte 5, which lives on the default branch (main).

If your PR concerns Svelte 4 (including updates to svelte.dev.docs), please ensure the base branch is svelte-4 and not main.

Before submitting the PR, please make sure you do the following

  • It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs
  • Prefix your PR title with feat:, fix:, chore:, or docs:.
  • This message body should clearly illustrate what problems it solves.
  • Ideally, include a test that fails without this PR but passes with it.

Tests and linting

  • Run the tests with pnpm test and lint the project with pnpm lint

Copy link

changeset-bot bot commented May 7, 2024

⚠️ No Changeset found

Latest commit: 0c89fee

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@FoHoOV FoHoOV changed the title chore: a new take on reactivity package fix: a new take on reactivity package May 7, 2024
@FoHoOV
Copy link
Author

FoHoOV commented May 8, 2024

This is still in draft mode because in this code:

<script>
	import {Set} from "svelte/reactivity";	

	const data = $state([1,2,3]);
	const reactiveSet = new Set(data);
	
	$effect(() => {
		console.log("has 1", reactiveSet.has(1));
	});

	$effect(() => {
		console.log("has 2", reactiveSet.has(2));
	});

	$effect(() => {
		console.log("has 3", reactiveSet.has(3));
	});

	$effect(() => {
		console.log("has 5", reactiveSet.has(5));
	});
</script>

<button onclick={()=>{
	reactiveSet.delete(2);
}}>
	delete 2
</button>


<button onclick={()=>{
	reactiveSet.add(2);
}}>
	add 2
</button>


<button onclick={()=>{
	reactiveSet.clear();
}}>
	clear
</button>

when we delete 2 from the set, all effects run where we call reactiveSet.has(SOMETHING). Keeping in mind that delete on a value that doesn't exist will not rerun the effects or deleting a value that exists twice doesn't rerun the effect for the second time. Following the current behaviour:

<script>
	import {Set} from "svelte/reactivity";	

	const data = $state([1,2]);
	const reactiveSet = new Set(data);
	
	$effect(() => {
		console.log("has 1", reactiveSet.has(1));
	});

	$effect(() => {
		console.log("has 2", reactiveSet.has(2));
	});
</script>

<button onclick={()=>{
	reactiveSet.delete(2);
}}>
	delete 2
</button>

after clicking on "delete 2" only reactiveSet.has(2) should get called (or things simillar to this case). Everything else works.

@7nik
Copy link

7nik commented May 8, 2024

As you saw, the general problem with this approach is that the object isn't fine-grained. You want

$effect(() => {
  console.log(reactiveMap.get(42));
});

run only when a value with the key 42 is added or set but not any other.
You can turn each reading method into a derived so that the effect won't rerun, but then, in a set/map with 10k items, each change will trigger 10k derives, which isn't performant. So, the most performant way is again to have a signal for each item, but you pay with memory for it.

There are also some debates about whether the stored item should be reactified.

@Azarattum
Copy link
Contributor

Azarattum commented May 9, 2024

I feel like this is quite nice generic solution for making arbitrary things reactive easily (e.g. objects from 3rd party libraries that you have no control over). However as @7nik pointed out, this isn't fine-grained. So, I think that basic things like Set/Map should be implemented manually, to make them as fine-grained as possible.

@FoHoOV
Copy link
Author

FoHoOV commented May 9, 2024

@7nik @Azarattum, I completely agree with you. I've marked this as a draft while I try to comeup with a solution (hopefully) that addresses this issue. However, as noted in issue #11515, the current implementation still faces a similar challenge from a different angle. For example, if a set contains a single item and we execute set.has(X) 1000 times within an effect, where X is not an element of the set, with each modification it results in at most 1000 has calls that haven't changed. Unless each of those have their own signals which results in 1000 signals that might be an unnecessary overhead. I'm honestly not sure how to resolve this effectively. Any ideas?

<script>
	import {Set} from "svelte/reactivity";	

	const data = [1];
	const reactiveSet = new Set(data);
	
	$effect(() => {
		console.log("has 1", reactiveSet.has(1));
	});

	$effect(() => {
		console.log("has 2", reactiveSet.has(2));
	        console.log("has 3", reactiveSet.has(3));
                // and so one for another 100000 elements that dont exist in the set and might never do!
	});
</script>

basically each read like method might create many unnecessary signals.

@Azarattum
Copy link
Contributor

Azarattum commented May 9, 2024

Well, to react to a 1000 items changing finely, we would need a 1000 signals. I think in this case this is what the user has requested explicitly, so it feels like an expected behavior. I think having a lot of signals that trigger less is better than a few that trigger often. This is what the fine-grainness is all about. Though I think that it would still be more common to have less signals than items in a set. So, lazy signal initialization shouldn't be an issue.

@7nik
Copy link

7nik commented May 9, 2024

I thought about storing in Map super items of shape { signal, value }, but this approach won't work for Set.
So, I lean toward storing values in the super and signals in a private map. Plus, it allows not to create signals for keys that were never read.

Regarding reactivity for non-existing keys, just creating and storing signals for them can cause a memory leak when somebody uses huge objects as keys or adds and removes thousands of unique keys. Temporary returning derived (kind of $deirvied( (this.#version, super.has(key)) )) may cause bad performance when somebody watches for thousands of non-existing keys simultaneously (though how likely it is?). Another alternative is StartStopNotifier, which removes the signal when nobody listens to it anymore.

@Azarattum
Copy link
Contributor

Azarattum commented May 10, 2024

@7nik, #11287 does kind of implement StartStopNotifer pattern. Though the implementation itself still isn't very elegant...

@03juan
Copy link

03juan commented May 10, 2024

So, I lean toward storing values in the super and signals in a private map. Plus, it allows not to create signals for keys that were never read.

Regarding reactivity for non-existing keys, just creating and storing signals for them can cause a memory leak when somebody uses huge objects as keys or adds and removes thousands of unique keys.

@7nik wouldn't this be a good candidate for a WeakMap for storing the signals, to not hold strongly to the user's potentially huge Map key or Set value?
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap#emulating_private_members

@FoHoOV FoHoOV marked this pull request as ready for review May 10, 2024 18:34
@FoHoOV
Copy link
Author

FoHoOV commented May 10, 2024

fixed some issues thanks to the comments above, also fixes #11515

@FoHoOV FoHoOV changed the title fix: a new take on reactivity package fix: generic utility for making built-in classes reactive May 12, 2024
@FoHoOV FoHoOV changed the title fix: generic utility for making built-in classes reactive fix: generic utility for making any classes reactive May 16, 2024
@FoHoOV FoHoOV changed the title fix: generic utility for making any classes reactive fix: generic utility for making any classe reactive May 16, 2024
@FoHoOV FoHoOV changed the title fix: generic utility for making any classe reactive fix: generic utility for making any class reactive May 16, 2024
@FoHoOV FoHoOV changed the title fix: generic utility for making any class reactive feat: generic utility for making any class reactive May 16, 2024
@FoHoOV FoHoOV force-pushed the improve-reactivity-package branch from ce43a21 to 31800ee Compare May 17, 2024 06:54
@FoHoOV
Copy link
Author

FoHoOV commented May 18, 2024

I came up with better idea to make it more accurate. Because currently it makes signals aware that a change "is going to happen", the correct implementation would be "a change has happened".
Edit: Fixed now

@FoHoOV FoHoOV marked this pull request as ready for review May 18, 2024 16:55
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

Successfully merging this pull request may close these issues.

None yet

4 participants