-
-
Notifications
You must be signed in to change notification settings - Fork 4.5k
breaking: finer lazy reactivity for set #11287
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
Conversation
|
If the idea goes through, we should implement the same strategy for map |
@paoloricciuti, you might be interested in this |
So let me see if i understand this correctly: this could still duplicate all the data in the Map but in the best case it will only keep in the map the things that i'm asking for Did i understood that correctly? |
@paoloricciuti, exactly. In the worst case it would match the current implementation, but on average should be much more memory efficient. We also get the benefit of tracking non-existent items without a version (which results in finer updates). Also initializing signals lazily might make it even more memory efficient than the approach in #11200 (but the difference here is negligible) |
Seems good but let's wait on some maintainers to validate. Will the Map implementation still be possible or there are unknowns? |
@Azarattum @paoloricciuti In the original issue I mentioned proxies, it has no data duplication and very easy to extend. set.js export const ReactiveSet = make_reactive(Set, {
mutation_properties: ['add', 'clear', 'delete']
}); utils.js /**
* @typedef {object} Options
* @prop {string[]} mutation_properties
*/
/**
* @template {new (...args: any) => any} TEntity
* @callback ReactiveEntityBuilder
* @param {...ConstructorParameters<TEntity>} params - parameters for TEntity constructor
* @returns {InstanceType<TEntity>}
*/
/**
* @template {new (...args: any) => any} TEntity - the entity we want to make reactive
* @param {TEntity} Entity - the class/function we want to make reactive
* @param {Options} options - configurations for how reactivity works for this entity
* @returns {ReactiveEntityBuilder<TEntity>}
*/
export const make_reactive = (Entity, options) => {
// @ts-ignore
return (...params) => {
const entity_instance = new Entity(...params);
return new Proxy(source(0), {
get(target, property, receiver) {
const orig_property = entity_instance[property];
let result;
if (typeof orig_property === 'function') {
// Bind functions directly to the Set
result = orig_property.bind(entity_instance);
} else {
// Properly handle getters
result = Reflect.get(entity_instance, property, entity_instance);
}
if (options.mutation_properties.some((v) => v == property)) {
set(target, target.v + 1);
} else {
get(target);
}
return result;
}
});
};
}; If you like the idea I can continue working on it because I actually don't know how svelte5 reactivity works under the hood. This works in playground but I don't know if this the correct way to do it. |
Let's wait on maintainers to take a look at this: the first thing I notice is that with this ReactiveSet is not a class but a function. Another possible drawback could be for functions that expect a normal set (you might not be able to pass this to those functions in TS). This is also registering accessing the source hence creating a dependency on get rather then apply (so for example doing But again let's wait on the far more experienced maintainers to take the decision, I'm just a guy lol |
Ow your absoulutly correct! thanks for the callouts. Most of them can be solved though. The good thing I like about this way is that we don't need a different implementation for each builtin and not data duplication. I also updated it a bit to address some of the issues (also I've removed all types for now cuz it needs more work): // set.js
// could be the same for other builtins
export const ReactiveSet = make_reactive(Set, {
mutation_properties: ['add', 'clear', 'delete'],
interceptors: {
add: (value, property, ...params) => {
return !value.has(params[0]);
},
clear: (value, property, ...params) => {
return value.size !== 0;
},
delete: (value, property, ...params) => {
return !value.has(params[0]);
}
}
});
// usage for now as you mentions is something like:
// const set = ReactiveSet([1, 2, 3]);
// but we can do some tricks to be able to call it with `new`
// also the return type can also be casted to `TEntity` which is `Set`, then TS won't yell
// utils.js
export const make_reactive = (Entity, options) => {
function notify_if_required(target, property, value, ...params) {
// interceptors - you return true if you want to notify reactions otherwise return false
if (options.interceptors?.[property]?.(value, property, ...params) === false) {
// if interceptor said to not make this call reactive(by returning false) then bailout
return;
}
if (options.mutation_properties.some((v) => v === property)) {
set(target, target.v + 1);
} else {
get(target);
}
}
// we can cast it to `()=>typeof Entity` which will trick TS into thinking this is a `Entity` (for instance a `Set`) rather than a `Source<number>`
// and actually it isn't because the proxy will only forward whatever is in the `Entity` and nothing else, so it actually behaves exactly the same as an Entity (for instance a `Set`)
return (...params) => {
const entity_instance = new Entity(...params);
return new Proxy(source(0), {
get(target, property) {
const orig_property = entity_instance[property];
let result;
if (typeof orig_property === 'function') {
// Bind functions directly to the Set
result = ((/** @type {any} */ ...params) => {
// this is called when the function is actually called rather than when we access it
// for instance if get `set.has` it will do nothing, but when we call it, reactivity will get invoked (based on the interceptors)
notify_if_required(target, property, entity_instance, ...params);
return orig_property.bind(entity_instance)(...params);
}).bind(entity_instance);
} else {
// Properly handle getters
result = Reflect.get(entity_instance, property, entity_instance);
notify_if_required(target, property, entity_instance);
}
return result;
}
});
};
}; |
I think you did enough work that it would be easier to just create a PR and reference this PR 😁 |
} | ||
|
||
effect(() => () => { |
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.
Not sure we want to invoke an effect as part of this method. It will add considerable overhead.
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.
Sure. I'm not that familiar with internals. Is there a better way to register a cleanup for a running effect?
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'm not sure this approach of needing to clean up is the right one. Why not keep the versioning logic from before?
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.
The versioning logic is still there for non fine-grained updates. Signals are now created only per user's request (e.g. with .has
). They should be cleaned up when they are no longer used anymore, though
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.
This is only required if .has is called from an effect. If we can somehow hook onto the parent's effect cleanup logic, that would be great
Can someone familiar with Svelte internals help refactor this part? effect(() => () => {
queueMicrotask(() => {
if (s && !s.reactions) {
this.#tracked.delete(value);
}
});
}); @trueadm pointed out that creating an effect here could be a performance concern, even though we don't use the effect itself only the cleanup logic. Is there a more direct way to add code to the parent's effect cleanup function without creating a nested effect? |
@Azarattum We need to avoid coding patterns that need a cleanup stage altogether really. Sets can be created outside of effects too, and these objects need to be robust enough to work in those cases without leaking too. |
@trueadm good point. If Do you think that adding an early return would make the code viable? Or would we still need to refactor the cleanup logic somehow? |
@Azarattum I'd prefer if we just avoided any need for cleanup entirely, so a refactor would be needed. It might make more sense to implement the individual methods one by one and then coming up with utility functions to handle the common cases. |
|
superseded by #11967, so i'll close this — thanks |
Svelte 5 rewrite
Following #11200, should fix #11222.
This implementation keeps all the data in the super set while maintaining minimal data duplication. This is achieved with lazy signal initialization. In set the only fine-grained method is
.has
therefore we can initialize signals only when requested by it. This also has a benefit of not firing effects when a value doesn't exist, hasn't been appended, but the version has changed (which is a breaking change, but it's a good one). Meaning:The implementation details are subject to discussion. Especially:
Is there a better way to cleanup unused signals?
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