How do one test a custom primitives?

Hi, I made an extension atop createStore for arrays as I use them a lot, and to provide some useful api for mutating it correctly. (I found general store use for this a little messy) The question is - how do I test if reactivity is preserved? More specifically, I have this test, which currently fails because the createEffect does not trigger and I don't quite know whether the test is wrong, or the implementation is (it could be both): The test
it('should be reactive', () => {
createRoot((dispose) => {
const store = createArrayStore<{ id: number }>();
let effectCount = 0;
createEffect(() => {
store.get();
effectCount++;
});

store.add({ id: 1 });
expect(effectCount).toBe(1); // Initial effect + one for the add

store.mutateElement({ id: 1 }, (el) => ({ ...el, id: 2 }));
expect(effectCount).toBe(2);

dispose();
});
});
it('should be reactive', () => {
createRoot((dispose) => {
const store = createArrayStore<{ id: number }>();
let effectCount = 0;
createEffect(() => {
store.get();
effectCount++;
});

store.add({ id: 1 });
expect(effectCount).toBe(1); // Initial effect + one for the add

store.mutateElement({ id: 1 }, (el) => ({ ...el, id: 2 }));
expect(effectCount).toBe(2);

dispose();
});
});
A subset of the code:
export function createArrayStore<T extends object>(initValue?: T[]): ArrayStore<T> {
const [currentValue, setStore] = createStore<T[]>(initValue ?? []);
return {
get: () => currentValue,
set: setStore,
add: (value: T) => {
setStore(prev => [...prev, value]);
return () => { //Returns a function for removing the element again
setStore(prev => prev.filter((v) => v !== value));
};
},
...
}
}
export function createArrayStore<T extends object>(initValue?: T[]): ArrayStore<T> {
const [currentValue, setStore] = createStore<T[]>(initValue ?? []);
return {
get: () => currentValue,
set: setStore,
add: (value: T) => {
setStore(prev => [...prev, value]);
return () => { //Returns a function for removing the element again
setStore(prev => prev.filter((v) => v !== value));
};
},
...
}
}
33 Replies
Alex Lohr
Alex Lohr3mo ago
I wrote testEffect in @solidjs/testing-library for that explicit purpose.
Fexelitche
FexelitcheOP3mo ago
Ah brilliant! I can see it triggering once on addition now, so thats great. It does feel a little wierd to provide an untracked lambda like that to make an explicit Accessor-like. But is that the way to do it with stores if you want to package it like this?
Alex Lohr
Alex Lohr3mo ago
The same, since you just use an effect in which you can evaluate the different stages of state in your store.
Fexelitche
FexelitcheOP3mo ago
Not quite sure I follow you there. I believe what is returned as "T[]" is a proxy, even though it looks like an ordinary value - which is why stores work in the first place. What exactly do you mean by using an effect (I assume you mean createEffect) when trying to wrap it like an Accessor?
Alex Lohr
Alex Lohr3mo ago
Like in this example I wrote in our testing docs: https://docs.solidjs.com/guides/testing#testing-effects just without the function call. If you access items within a store, the corresponding signals will be tracked by the proxy.
Fexelitche
FexelitcheOP3mo ago
Man... You know how - when you actually take the time to understand how it works, it makes a lot more sense? One thing tho: It doesn't seem like createEffect runs once on "mount" when within a returned testEffect in contrast to how createEffect behaves in components - is this intentional?
it('does testEffect run once on "mount" always - no?', () => {
return testEffect((dispose) => {
const store = createArrayStore<{ id: number }>();
let effectCount = 0;
createEffect(() => {
store.get();
effectCount++;
});

expect(effectCount).toBe(0);

dispose();
});
})
it('does testEffect run once on "mount" always - no?', () => {
return testEffect((dispose) => {
const store = createArrayStore<{ id: number }>();
let effectCount = 0;
createEffect(() => {
store.get();
effectCount++;
});

expect(effectCount).toBe(0);

dispose();
});
})
Alex Lohr
Alex Lohr3mo ago
Since subscription is lazy, the first run is required to subscribe to all the signals inside the tracking scope.
Fexelitche
FexelitcheOP3mo ago
Well, maybe you can answer this: Will invoking a StoreSetter function (as returned by createStore) always cause a reactive update, or only if top-level object has changed? I can't exactly determine whether it relies on object equality like React from neither the examples nor the docs. (Object equality as in - different memory addresses, meaning youd always have to allocate new memory to cause an update) Specifically https://docs.solidjs.com/concepts/stores under "Modifying objects" doesn't exactly make it clear whether it just does the reallocation for you in that scenario, or if it doesn't rely on object equality in the first place
Alex Lohr
Alex Lohr3mo ago
There is an equality comparison before the reactivity is triggered - the exception is for signals if you use the { equals: false } option. Since stores lazily create signals for items within them that are accessed through the proxy, the usual equality check applies.
Fexelitche
FexelitcheOP3mo ago
So what I'm hearing you say, is that the object equality check, is not on the top-level object or array, but rather each element or field?
Alex Lohr
Alex Lohr3mo ago
each accessed leaf of the object tree. or node
Fexelitche
FexelitcheOP3mo ago
Well that's... actually pretty neat, but I'd never had guessed that from the docs. Any way to turn on a top-level check? Like in a case where I know I want a reactive update, even though no "leaves" have been mutated?
Alex Lohr
Alex Lohr3mo ago
The root of a store is always treated as immutable. only the nodes are reactive.
Fexelitche
FexelitcheOP3mo ago
Darn. Oh well, - as for the arborial behaviour, wouldn't that break on sort operations on arrays? Since the leaves arents reallocated, but reordered. Something you would want an update for
Alex Lohr
Alex Lohr3mo ago
yes, you need to create a new array from the sorted one or use reconcile. setStore('array', [...unsortedArray.sort()])
Fexelitche
FexelitcheOP3mo ago
Hang on, wouldn't that also mean, that since the top-level node, is never checked (concidered immutable), that if you intend to sort an array and react to that, it has to be encapsulated in some other top-level node? So that it can be concidered a leaf? Or, reversely, encapsulate the store in a signal, so that you can replace the entire thing and react to that instead.
Alex Lohr
Alex Lohr3mo ago
using a top-level array will also work, but then, you need produce: setStore(produce((s) => s.sort());
Fexelitche
FexelitcheOP3mo ago
Would optionally enabling root-node checks be a good candidate for a PR? One last question as for how store trees work: How deep does it go? It seems like values are only tracked one layer down from root. Meaning if you had a store of an object with an array (so the entire array can be reactive), and objects in that array, the array itself is tracked, but no fields in each object is?
Alex Lohr
Alex Lohr3mo ago
As long as it is arrays and objects, it goes as deep as the original object goes. It doesn't do other class instances, though, and treats them as leaves. If you want a one-level static store, there is @solid-primitives/static-store, a community primitive. It is a bit more performant than store, but has less features, obv.
Fexelitche
FexelitcheOP3mo ago
That is not what I'm experiencing. Take the nested if statement here, currently I'm reallocating - which I shouldn't have to if reactivity extended arbitrarily, but it fails to cause an update regardless of what I do aside from reallocating everything:
const [proxy, setStore] = createStore<T[]>(initValue ?? []);

mutateByPredicate: (predicate: (element: T) => boolean, mutator: (element: T) => T | void) => {
setStore((prev) => {
for (let i = 0; i < prev.length; i++) {
if (predicate(prev[i])) {
const newElement = mutator(prev[i]);
if (newElement !== undefined && newElement !== prev[i]) {
prev[i] = newElement;
} else {
prev[i] = {...prev[i]};
}
}
}
return prev;
});
},
const [proxy, setStore] = createStore<T[]>(initValue ?? []);

mutateByPredicate: (predicate: (element: T) => boolean, mutator: (element: T) => T | void) => {
setStore((prev) => {
for (let i = 0; i < prev.length; i++) {
if (predicate(prev[i])) {
const newElement = mutator(prev[i]);
if (newElement !== undefined && newElement !== prev[i]) {
prev[i] = newElement;
} else {
prev[i] = {...prev[i]};
}
}
}
return prev;
});
},
Admittedly, writing tests for this have been... difficult, so the issue might not be the current implementation:
it('mutateByPredicate should be reactive (object replace)', () => {
return testEffect((dispose) => {
const store = createArrayStore<{ id: number }>([{ id: 0 }, { id: 1 }]);
let triggerCount = 0;

createEffect(() => {
const items = store.get();
const currentID = items[0].id;
if(currentID === 10) {
expect(triggerCount).toBe(10);
dispose();
}
const mutationCount = store.mutateByPredicate(
(el) => el.id === currentID,
(el) => ({ id: el.id + 1 })
);
expect(mutationCount).toBe(1);
triggerCount++;
});
});
});
it('mutateByPredicate should be reactive (object replace)', () => {
return testEffect((dispose) => {
const store = createArrayStore<{ id: number }>([{ id: 0 }, { id: 1 }]);
let triggerCount = 0;

createEffect(() => {
const items = store.get();
const currentID = items[0].id;
if(currentID === 10) {
expect(triggerCount).toBe(10);
dispose();
}
const mutationCount = store.mutateByPredicate(
(el) => el.id === currentID,
(el) => ({ id: el.id + 1 })
);
expect(mutationCount).toBe(1);
triggerCount++;
});
});
});
^ fail by time out
Alex Lohr
Alex Lohr3mo ago
Does mutator actually mutate prev[i]? No, it returns a new object. but you mutate inside the setStore function, which is plain wrong.
Fexelitche
FexelitcheOP3mo ago
That's optional. You can mutate the existing or replace it. Admittedly, that fails too for object replace
Alex Lohr
Alex Lohr3mo ago
You need produce to mutate. Or use the setStore patterns to select what you want to change. So you would better iterate outside of setStore and only call setStore(i, ...) to mutate the store. Maybe batch the whole thing.
mutateByPredicate: (predicate: (element: T) => boolean, mutator: (element: T) => T | void) => {
for (let i = 0; i < proxy.length; i++) {
if (predicate(prev[i])) {
const newElement = mutator(prev[i]);
if (newElement !== undefined && newElement !== prev[i]) {
setStore(i, newElement);
} else { // not sure why you would want to make a non-change reactive?
setStore(i, {...proxy[i]});
}
}
}
},
mutateByPredicate: (predicate: (element: T) => boolean, mutator: (element: T) => T | void) => {
for (let i = 0; i < proxy.length; i++) {
if (predicate(prev[i])) {
const newElement = mutator(prev[i]);
if (newElement !== undefined && newElement !== prev[i]) {
setStore(i, newElement);
} else { // not sure why you would want to make a non-change reactive?
setStore(i, {...proxy[i]});
}
}
}
},
Maybe wrap the whole thing in batch(() => { for ... }) so the changes will occur all at once.
Fexelitche
FexelitcheOP3mo ago
I'd like to do that too, but the proxy doesn't seem to update synchronously, so I've been unable to retrieve the most recent state of the array. I'll try a rewrite using the proxy, but I've had nothing but issues
Alex Lohr
Alex Lohr3mo ago
It doesn't update if you do it like you did. because mutation is not supported by setStore unless you use produce() At least there's no reactive tracking otherwise.
Fexelitche
FexelitcheOP3mo ago
Ahh. So its an ouroboros
Alex Lohr
Alex Lohr3mo ago
? I did not catch that reference.
Fexelitche
FexelitcheOP3mo ago
A snake biting its own tail. - The proxy doesn't work, because I mutate in setStore (and wrongly so), and accessing current state of the proxy outside of setStore doesn't work, because I mutate in setStore... As for "non-change" - it could be an in-place change, like changing a sub-field on the object. Reallocating should make sure that in-place changes still trigger updates.
Alex Lohr
Alex Lohr3mo ago
That's one of the use cases where a top level array store is not ideal. because otherwise if an array node changes, you get a reactive update for the indices. There are a few cases where arrays + stores are a less than ideal combination. If you need to splice an item, for example
Fexelitche
FexelitcheOP3mo ago
Not sure I follow. Isn't that exactly what you'd want. If there is a change to some object at index i, you would want an update for that index? Also, with a purpose build primitive, maybe some of those less ideal cases can be remedied? Sure you can't for the generic store, but if you know you're dealing with a top-level array, you could maybe mitigate the mayhem?
Alex Lohr
Alex Lohr3mo ago
Unfortunately, parts of these issues are due to Arrays, not to stores - and For only iterates over non-sparse arrays. I'm still trying to wrap my head around other concepts. I've tried a linked list once.
Fexelitche
FexelitcheOP3mo ago
Yeah well I'm still trying to wrap my head around JS it seems... What is going on here even?
const index = proxy.findIndex(e => {
console.log("e", JSON.stringify(e), "element", JSON.stringify(element), " === ", e === element);
return e == element
});

Logs:
e {"id":1} element {"id":1} === false
index -1
e {"id":2} element {"id":2} === false
index -1
e {"id":1,"value":"old"} element {"id":1,"value":"old"} === false
index -1
const index = proxy.findIndex(e => {
console.log("e", JSON.stringify(e), "element", JSON.stringify(element), " === ", e === element);
return e == element
});

Logs:
e {"id":1} element {"id":1} === false
index -1
e {"id":2} element {"id":2} === false
index -1
e {"id":1,"value":"old"} element {"id":1,"value":"old"} === false
index -1
Object equavalence fails, string version fails, weak object equivalence fails ("==")... I'm at a loss. Damn. Nvm, its just stores, not JS. Object equivalence is lost because all values are proxied. Is there a way to retrieve the underlying value so that one can use object equivalence?
REEEEE
REEEEE3mo ago
you can use unwrap
Want results from more Discord servers?
Add your server